const fs = require('fs') const path = require('path') const db = require('./shardAssets.db') const atlasDb = require('../shardAtlas/shardAtlas.db') const core = require('../../core') const bridge = require('../../utils/assetBridge') const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model') const log = require('../../core').logger('shardAssets') // Client artwork, over the bridge (docs/link/v8.md — protocol 8, phase 3). // // What this replaces: 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 and the atlas rendered as text. // // The shard has had those files the whole time — a ServUO server cannot boot // without a UO client — so as of protocol 8 it decodes them itself and hands the // pictures over the same request/reply path as every other shard read. // // ── Two passes, and they answer different questions ─────────────────── // // **The catalogue** (§4.8, §11) is one thumbnail per creature body: the shard // walks bodies 0–2047, validates each index entry, decodes the ones that are real // and hands back `{ key, sha256 }` first and the PNG second. On a stock client // that is **787 sprites**, not the 1,144 the decoder claims — see below. // // **Body resolution** (§8) is the join. The atlas knows a creature by the class // name in `Spawns/*.xml`; the client knows it by a body id; nothing in the ServUO // tree declares the mapping. Only code inside ServUO can answer it, by // constructing the creature and reading `Body.BodyID`, and that is the whole // reason this could not be done off the shard. // // ── The 357, and why nothing here trusts a success ──────────────────── // // 357 of the bodies ServUO's decoder returns a bitmap for **have no art**. Their // index entry reads `length 0`, the library's stream buffer still holds the // previous creature, and what comes back is whichever body was decoded before — // a real, plausible, correctly-sized picture of the wrong animal. The shard now // validates every index entry before it decodes, which is what cut the catalogue // from 1,144 to 787, and the count going down is the point. // // The consequence for this file is a rule: **a missing asset is a normal // outcome, never an error.** Two thirds of the player bodies have no art on a // stock client (§5.2), so an import that reported eight failures every time would // teach an operator to ignore the panel. // // ── Where the pictures go, and what still wins ──────────────────────── // // Into `/atlas/`, through the same door the operator's own artwork uses, // and `shard_spawn_creatures.art` is DERIVED from them rather than written by // them. **The operator's `spawnAtlas.art.json` still wins outright**: someone who // has drawn their own creature portraits must not have them replaced by a sprite // rip on the next Update. // // ── Why the resolution does not live on the atlas row ───────────────── // // `shard_spawn_creatures` is emptied and refilled on every atlas refresh. A body // id or a filename stored there would be destroyed by an ordinary re-parse of the // ServUO tree, and the next asset Update would find the client files unchanged, // report "nothing to do" and never restore it. So both live in their own tables // and the atlas import reads them on the way past. /** Where imported sprites land, under core's upload directory. */ const ART_SUBDIR = 'atlas' // ── configuration ────────────────────────────────────────────────────────── /** * Is there a shard to ask? * * Both halves matter, exactly as in `shardClilocs.model`: a `baseUrl` on a * disabled config is an install that was set up and then switched off, and * calling it would spend a 12 s timeout to learn what the row already says. */ async function shardLinked() { try { const config = await uoLinkConfig.getSafe() return Boolean(config?.enabled && config?.baseUrl) } catch { return false } } function artDir() { return path.join(core.uploads.UPLOAD_DIR, ART_SUBDIR) } /** * The operator's own art map, which wins over anything imported. * * Read through the atlas model rather than re-implemented, so there is one * definition of where that file lives and what an absent one means. */ function operatorArt() { // eslint-disable-next-line global-require return require('../shardAtlas/shardAtlas.model').loadArtMap() } // ── writing a sprite ─────────────────────────────────────────────────────── /** * The filename one asset gets on disk. * * **Content-addressed on purpose.** A stable name per key (`uo-body-34.png`) * would be overwritten in place by an Update, and every browser and CDN that had * already cached it would keep serving last month's client's sprite — with * nothing anywhere to notice, because the database row would be correct. Putting * eight bytes of the hash in the name makes a changed sprite a changed URL. * * The old file is removed when a key's hash moves, so the directory tracks the * catalogue rather than accumulating one file per import forever. */ function fileNameFor(key, sha256) { const stem = key.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') return `uo-${stem}-${String(sha256).slice(0, 8)}.png` } /** * Write one sprite and return its filename, or null if it could not be written. * * Never throws. A full disk or a read-only volume must degrade to "this creature * has no picture" — which the whole site already renders correctly, because it is * the state every install was in until this phase — rather than failing an import * that has already fetched hundreds of others. */ function writeSprite(key, sha256, png) { const name = fileNameFor(key, sha256) try { fs.mkdirSync(artDir(), { recursive: true }) fs.writeFileSync(path.join(artDir(), name), png) return name } catch (err) { log.warn('could not write an imported sprite', { key, error: err.message }) return null } } /** Best-effort removal of a sprite a key no longer points at. */ function removeSprite(name) { if (!name) return try { fs.unlinkSync(path.join(artDir(), name)) } catch { // Already gone, or never written. Either way there is nothing to do, and an // import must not fail because a file it was tidying up was tidied already. } } // ── the import ───────────────────────────────────────────────────────────── /** * Import (or update) the body catalogue and the slug → body map. * * Returns a result rather than throwing, so a controller can render it and an * operator can read it: * * `skipped` no shard configured — the file era had no equivalent here * `unavailable` the shard could not answer (down, plane off, no libgdiplus) * `unchanged` the client files match what was imported; nothing fetched * `imported` fetched and applied * `needsReview` a key we hold has vanished from the shard's manifest * `failed` something went wrong mid-import * * `force` re-imports even when the client files are unchanged (which is also how * an operator recovers from a deleted uploads directory — the database still * holds the hashes, but the files behind them are gone). `approve` accepts a * catalogue that no longer offers keys we hold. */ async function importAssets({ force = false, approve = false } = {}) { if (!(await shardLinked())) { return { status: 'skipped', reason: 'uo-link is not configured, so there is no shard to read client files from', } } let sources try { sources = await bridge.sourceFingerprint() } catch (err) { return failure(err, 'client file manifest') } // §4.4: a Linux shard host without libgdiplus cannot render a sprite at all. // It is reported on the source gate precisely so an operator meets it while // setting the shard up rather than from an empty bestiary weeks later. if (sources.imaging && sources.imaging.ok === false) { return { status: 'unavailable', code: 'NO_IMAGING', reason: sources.imaging.reason || 'the shard host cannot render images', } } const meta = await db.getMeta().catch(() => null) if (!force && bridge.sameSources(sources, meta?.sources)) { const counts = await db.countAssets() const bodies = await db.countBodies() return { status: 'unchanged', assets: counts.total, stored: counts.stored, bodies: bodies.resolved, hashing: sources.hashing, importedAt: meta?.importedAt ?? null, } } let manifest try { manifest = await bridge.readManifest({ family: bridge.FAMILY }) } catch (err) { return failure(err, 'asset manifest') } const held = await db.allAssets() const offered = new Set(manifest.rows.map((r) => r.key)) // A key we hold that the shard no longer offers. An unmounted client volume and // a deliberate downgrade look identical from here, and the wrong guess deletes // artwork, so it is staged rather than applied — the same rule, and the same // reasoning, as a vanished cliloc overlay or a disappearing atlas facet. const vanished = [...held.keys()].filter((key) => !offered.has(key)) if (vanished.length > 0 && !approve) { return { status: 'needsReview', reason: `${vanished.length} asset(s) this site holds are no longer offered by the shard; ` + 'nothing was changed', vanished: vanished.slice(0, 50), vanishedCount: vanished.length, } } // The diff, and the whole reason stage 2 carries hashes and not pixels. An // unchanged key is skipped ONLY if its file is actually still on disk: the row // and the file can disagree (a wiped uploads volume, a restore from a database // dump), and re-fetching a sprite is far cheaper than a creature page with a // broken image on it. const wanted = manifest.rows.filter((row) => { const existing = held.get(row.key) if (!existing || existing.sha256 !== row.sha256) return true if (!existing.file) return true return !fs.existsSync(path.join(artDir(), existing.file)) }) let fetched = { assets: new Map(), missing: { absent: 0, unsupported: 0 } } if (wanted.length > 0) { try { fetched = await bridge.fetchAssets({ keys: wanted.map((r) => r.key), catalog: manifest.catalog, }) } catch (err) { return failure(err, 'asset content') } } const rows = [] let written = 0 for (const row of manifest.rows) { const existing = held.get(row.key) const got = fetched.assets.get(row.key) if (!got) { // Either it was unchanged and skipped, or the shard could not serve it. The // row is kept either way, with whatever file it already had — a key the // shard suddenly cannot render must not lose the picture we already hold. rows.push({ ...row, file: existing?.file ?? null }) continue } const name = writeSprite(row.key, got.sha256, got.png) if (name) { written++ if (existing?.file && existing.file !== name) removeSprite(existing.file) } rows.push({ ...row, sha256: got.sha256 || row.sha256, bytes: got.bytes || row.bytes, width: got.width || row.width, height: got.height || row.height, body: got.body ?? row.body, direction: got.direction ?? row.direction, file: name ?? existing?.file ?? null, }) } const removed = [] if (vanished.length > 0) { for (const key of vanished) { removeSprite(held.get(key)?.file) removed.push(key) } } try { await db.saveAssets(rows, { catalog: manifest.catalog, extractorVersion: manifest.extractorVersion, family: bridge.FAMILY, playerBodies: manifest.playerBodies, sources: { files: sources.files, extractorVersion: sources.extractorVersion }, count: rows.length, }) } catch (err) { return { status: 'failed', reason: err.message } } const bodies = await resolveAtlasBodies() const art = await applyArt() log.info('asset import applied', { assets: rows.length, fetched: fetched.assets.size, written, absent: fetched.missing.absent, bodies: bodies.resolved, art: art.applied, }) return { status: 'imported', catalog: manifest.catalog, extractorVersion: manifest.extractorVersion, assets: rows.length, fetched: fetched.assets.size, written, absent: fetched.missing.absent, unsupported: fetched.missing.unsupported, removed: removed.length, scanned: manifest.scanned, pages: manifest.pages, playerBodies: manifest.playerBodies, bodies, art, } } function failure(err, what) { if (err instanceof bridge.AssetBridgeError) { return { status: 'unavailable', code: err.code, reason: err.message } } log.warn(`asset import failed reading the ${what}`, { error: err.message }) return { status: 'failed', reason: err.message } } // ── the body pass (§8) ───────────────────────────────────────────────────── /** * Ask the shard for a body id for every creature the atlas knows. * * `shard_spawn_creatures.name` is the ServUO class name — the atlas build picks * the winning spelling of the spawn TYPE token rather than inventing a display * name — so this needs no new column to ask its question. * * Never throws: a shard that goes down between the asset fetch and this pass * leaves the assets imported and the map as it was, which is a strictly better * state than failing the whole import back to nothing. */ async function resolveAtlasBodies() { let creatures = [] try { creatures = await atlasDb.allCreatureTypes() } catch (err) { return { resolved: 0, asked: 0, reason: err.message } } if (creatures.length === 0) { return { resolved: 0, asked: 0, reason: 'the spawn atlas has no creatures loaded' } } let rows try { rows = await bridge.resolveBodies({ creatures }) } catch (err) { return { resolved: 0, asked: creatures.length, reason: err.message } } try { await db.replaceBodies(rows) } catch (err) { return { resolved: 0, asked: creatures.length, reason: err.message } } const tally = { ok: 0, unknown: 0, notCreature: 0, failed: 0 } for (const row of rows) { if (tally[row.status] === undefined) tally.failed++ else tally[row.status]++ } return { asked: creatures.length, answered: rows.length, resolved: tally.ok, tally } } // ── the derivation (§12) ─────────────────────────────────────────────────── /** * Point every atlas creature at its imported portrait. * * Two rules, and the second is the one worth stating: * * 1. The operator's `spawnAtlas.art.json` wins. Someone who drew their own * creature portraits must not have them replaced by a sprite rip. * 2. A slug with neither is set back to NULL rather than left alone. A creature * whose body stopped resolving — the operator removed a script package, say * — would otherwise keep pointing at a file that is about to be deleted, and * a broken image is worse than no image. */ async function applyArt() { const derived = await db.artBySlug() const operator = operatorArt() const map = { ...derived, ...operator } try { const applied = await atlasDb.setCreatureArt(map) return { applied, derived: Object.keys(derived).length, operator: Object.keys(operator).length } } catch (err) { log.warn('could not apply imported creature art', { error: err.message }) return { applied: 0, derived: Object.keys(derived).length, error: err.message } } } // ── status ───────────────────────────────────────────────────────────────── /** * What the admin panel renders: what is loaded, what the shard says, and whether * the two agree. * * Never throws and never fails a page: every branch that could — no shard, a * shard that is down, an asset plane the operator switched off — is a reported * state with a reason an operator can act on. */ async function getStatus() { const counts = await db.countAssets().catch(() => ({ total: 0, stored: 0 })) const bodies = await db.countBodies().catch(() => ({ total: 0, resolved: 0 })) const meta = await db.getMeta().catch(() => null) const families = await db.countByFamily().catch(() => ({})) const status = { loaded: { assets: counts.total, stored: counts.stored, creatures: bodies.total, resolved: bodies.resolved, catalog: meta?.catalog ?? null, extractorVersion: meta?.extractorVersion ?? null, importedAt: meta?.importedAt ?? null, // Item and land pictures, counted separately because they are a different // KIND of thing (§11, phase 5): no manifest, no set, and no "how many are // there" to compare against. `items` is how many the site has been asked // for and holds, which is the only number that means anything here. items: families.static?.stored ?? 0, land: families.land?.stored ?? 0, }, shard: null, drift: null, } if (!(await shardLinked())) { status.reason = 'uo-link is not configured' return status } try { const sources = await bridge.sourceFingerprint() status.shard = { files: Object.keys(sources.files).length, extractorVersion: sources.extractorVersion, hashing: sources.hashing, complete: sources.complete, imaging: sources.imaging, // Which §5 families this overlay serves. A phase-3 or phase-4 overlay says // `['body']`, which is what an admin panel needs in order to say "update // your plugin" rather than showing an item-art pipeline that cannot work. families: sources.families, } status.drift = meta ? !bridge.sameSources(sources, meta.sources) : true } catch (err) { status.reason = err.message status.code = err instanceof bridge.AssetBridgeError ? err.code : 'UNAVAILABLE' } return status } module.exports = { ART_SUBDIR, artDir, fileNameFor, importAssets, resolveAtlasBodies, applyArt, getStatus, }