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$/) })