Files
Module-uo/server/test/shardAssets.model.test.js
wtclaude 675e879b48
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m4s
PR Checks / client-build (pull_request) Failing after 14m21s
feat(assets): the panel that operates the client-file imports (Phase 8)
Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.

The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.

Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.

The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:

  * The body import diffed its manifest against every family's rows. Phase 5 put
    item and land art in the same table, and a body manifest never mentions
    them, so all 313 item pictures were staged for deletion with a sentence
    saying the shard had stopped offering them.
  * An approved vanish unlinked the sprite and kept the row. The catalogue went
    on counting a picture that was gone, the atlas could point a creature page at
    a missing file, and the next forced import offered the same key for review
    again -- reporting "nothing was changed" about a file it had deleted.

Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.

Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.

Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)

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

560 lines
20 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.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)
})