Files
Module-uo/server/model/shardAssets/shardAssets.model.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

589 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 02047, 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 **1,095 sprites** — 787 out of the legacy anim files, 235 more out of
// the UOP packages (phase 4), and 73 more since phase 6, which have no art at
// action 0 and real art at a later one. Never the 1,144 the decoder claims.
//
// A key therefore names its action — `body/820/a23` is a horse whose action 0 is
// empty — and the key is still one per body. Nothing here treats `a0` as the
// shape of a body key; the atlas join reads the row's own action (§11.2).
//
// **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 `<uploads>/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.
*
* `by` is who pressed the button, carried through only so the panel can say what
* the last import did and who ran it without reading the audit log (phase 8). It
* decides nothing.
*/
async function importAssets({ force = false, approve = false, by = null } = {}) {
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(bridge.FAMILY)
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')
}
// The body family only. This diff decides what gets DELETED, and the manifest
// it is diffed against is of one family by construction — so reading the whole
// table here stages every item picture phase 5 warmed as a vanished key.
const held = await db.allAssets(bridge.FAMILY)
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',
// Each one carries the picture it currently has, because the decision the
// operator is being asked for is "is it right that these disappear?" and a
// list of keys cannot be looked at. `body/820/a23` names nothing a human
// recognises; the horse it is a picture of does.
vanished: vanished.slice(0, 50).map((key) => ({ key, file: held.get(key)?.file ?? null })),
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,
action: got.action ?? row.action ?? 0,
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,
},
// The approved removals go in with the write. The sprite is already
// unlinked above; leaving the row behind would keep counting a picture
// that is gone and re-offer the same key for review on every import.
removed,
)
} catch (err) {
return { status: 'failed', reason: err.message }
}
const bodies = await resolveAtlasBodies()
const art = await applyArt()
// What this run did, kept beside the catalogue it produced (phase 8). The admin
// panel renders it as "the last import", which is the question an operator has
// straight after pressing a button that takes a minute and prints nothing:
// what changed, and did the body pass find drift. Core's activity log records
// the same action, but it is one unfiltered list of every admin action on the
// site, so an import from three client patches ago is not findable there.
//
// Best-effort on purpose: the import has already applied, and losing a cosmetic
// summary must not turn a successful import into a failure.
const last = {
at: new Date().toISOString(),
by,
force,
approve,
assets: rows.length,
fetched: fetched.assets.size,
written,
removed: removed.length,
absent: fetched.missing.absent,
unsupported: fetched.missing.unsupported,
bodies: bodies.tally ?? null,
art: art.applied ?? 0,
}
try {
await db.recordLastImport(last)
} catch (err) {
log.warn('could not record the import summary', { error: err.message })
}
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() {
// The BODY family, not the whole table: item and land art live here too and
// are reported separately below, because they are a working set rather than a
// catalogue with a size (§11).
const counts = await db.countAssets(bridge.FAMILY).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 = {
// Is there a shard to ask at all? Stated rather than left to be inferred:
// the panel disables its import buttons on it, and the alternative — reading
// it out of `reason`'s wording, or out of `shard` being null, which is also
// what a shard that is merely DOWN looks like — is a sentence that decides
// behaviour.
linked: await shardLinked(),
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,
// What the last import did, and who ran it (phase 8). Null on an install
// that has never imported, and on one whose last import predates this
// field — both of which render as "no import recorded" rather than as
// zeroes, because an import that fetched nothing is a real and different
// answer from one that never happened.
last: meta?.last ?? null,
},
shard: null,
drift: null,
}
if (!status.linked) {
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,
}