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.recordLastImport = db.recordLastImport 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, last: 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.recordLastImport = async (last) => { seen.last = last } 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$/) }) // ── what the panel reads (phase 8) ──────────────────────────────────────── // // The admin surface is the only thing that imports — boot never calls the shard // — so everything an operator can learn about an import, they learn from what // these two return. Each of these is a way the panel would render a confident // sentence that is not true. test('the vanished keys come back with the pictures they currently have', async (t) => { useTempUploads(t) const held = new Map([ ['body/820/a23', { key: 'body/820/a23', sha256: 'a', file: 'uo-body-820-a23-aabbccdd.png' }], ]) stubEverything({ held, manifest: manifestOf([row(12, 'a')]) }) t.after(restore) const result = await model.importAssets({ force: true }) // The decision being asked for is "is it right that these disappear?", and a // key names nothing a human recognises. Without the filename the panel has // nothing to show but `body/820/a23`, which is a horse. assert.equal(result.status, 'needsReview') assert.deepEqual(result.vanished, [ { key: 'body/820/a23', file: 'uo-body-820-a23-aabbccdd.png' }, ]) }) test('an import records what it did, including the body tally and who ran it', async (t) => { useTempUploads(t) const seen = stubEverything({ manifest: manifestOf([row(12, 'new')]), fetched: { assets: new Map([['body/12/a0', sprite('new')]]), missing: { absent: 3, unsupported: 0 }, }, }) t.after(restore) atlasDb.allCreatureTypes = async () => [{ slug: 'wolf', name: 'Wolf' }] bridge.resolveBodies = async () => [ { slug: 'wolf', typeName: 'Wolf', body: 34, status: 'ok' }, { slug: 'ghost-of-something', typeName: 'GhostOfSomething', body: null, status: 'unknown' }, ] await model.importAssets({ force: true, by: 'colby' }) assert.equal(seen.last.by, 'colby') assert.equal(seen.last.force, true) assert.equal(seen.last.written, 1) assert.equal(seen.last.absent, 3) // The body pass is kept as a TALLY rather than a single "resolved" number: // `unknown` means the spawn files name a type this shard's scripts do not // define, which is real drift, and it reads identically to a failure if both // are summed into "not resolved". assert.deepEqual(seen.last.bodies, { ok: 1, unknown: 1, notCreature: 0, failed: 0 }) }) test('a summary that cannot be written does not fail an import that applied', async (t) => { useTempUploads(t) stubEverything({ manifest: manifestOf([row(12, 'new')]), fetched: { assets: new Map([['body/12/a0', sprite('new')]]), missing: { absent: 0, unsupported: 0 }, }, }) t.after(restore) db.recordLastImport = async () => { throw new Error('the meta row is locked') } // The pictures are already on disk and the rows are already committed. Failing // here would report a failure for an import that succeeded, and the operator's // next move — press it again — would re-fetch the whole catalogue for nothing. const result = await model.importAssets({ force: true }) assert.equal(result.status, 'imported') assert.equal(result.written, 1) }) test('status says whether a shard is linked rather than leaving it to be inferred', async (t) => { stubEverything({ manifest: manifestOf([]) }) t.after(restore) db.getMeta = async () => ({ catalog: 'cat1', last: { by: 'colby', written: 4 } }) const linked = await model.getStatus() assert.equal(linked.linked, true) assert.deepEqual(linked.loaded.last, { by: 'colby', written: 4 }) // A shard that is linked but DOWN also reports `shard: null`, which is why the // panel cannot read this off that: one wants its buttons disabled and the // other wants them available so the operator can retry. uoLinkConfig.getSafe = async () => ({ enabled: false, baseUrl: '' }) const unlinked = await model.getStatus() assert.equal(unlinked.linked, false) assert.equal(unlinked.reason, 'uo-link is not configured') }) test('the catalogue count is the body family, not every asset in the table', async (t) => { stubEverything({ manifest: manifestOf([]) }) t.after(restore) let askedFor = 'never called' // Item and land art live in the same table as the body catalogue (phase 5) and // are counted separately on purpose: one is a set with a size, the other is // however much of an unbounded space the site has happened to ask for. A // whole-table count reported 1,095 portraits plus 313 item pictures as a // "1,408-row catalogue" on the one screen that answers "did the import work". db.countAssets = async (family) => { askedFor = family return { total: 1095, stored: 1095 } } const status = await model.getStatus() assert.equal(askedFor, 'body') assert.equal(status.loaded.assets, 1095) }) test('item pictures are not "vanished" just because the body manifest never listed them', async (t) => { useTempUploads(t) // The state every install reaches within a day of its first import: a body // catalogue, plus whatever item art the warm pass has fetched because a // marketplace page asked for it. Both live in `shard_assets`. const held = new Map([ ['body/12/a0', { key: 'body/12/a0', family: 'body', sha256: 'a', file: 'wolf.png' }], ['static/3934/h1801', { key: 'static/3934/h1801', family: 'static', sha256: 'b', file: 'robe.png' }], ]) const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) }) t.after(restore) // The family filter is the fix, so the stub has to honour it or the test // passes against a whole-table read. db.allAssets = async (family) => new Map([...held].filter(([, r]) => !family || r.family === family)) const result = await model.importAssets({ force: true }) // Before the filter this was `needsReview` naming the item picture, and // approving it would have deleted every picture the warm pass had fetched — // with a sentence saying the shard had stopped offering them, which it had // not: a body manifest never mentions item art at all. assert.equal(result.status, 'imported') assert.equal(result.removed, 0) assert.ok(seen.saved) }) test('an approved vanish deletes the row, not just the picture', 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', family: 'body', sha256: 'a', file: 'gone.png' }], ]) let removedKeys = null const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) }) t.after(restore) db.saveAssets = async (rows, meta, remove) => { seen.saved = rows removedKeys = remove return rows.length } const result = await model.importAssets({ force: true, approve: true }) assert.equal(result.removed, 1) // The file was already unlinked before this fix; the ROW was not. A row whose // picture is gone keeps being counted, keeps being offered for review on every // forced import, and can still point a creature page at a file that is not // there — with the import reporting "nothing was changed" about a deletion it // had already performed. assert.deepEqual(removedKeys, ['body/99/a0']) assert.equal(fs.existsSync(path.join(dir, model.ART_SUBDIR, 'gone.png')), false) })