Files
Module-uo/server/model/shardAssets/shardItemArt.model.js
wtclaude c6c51b190d
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 8m9s
PR Checks / server-tests (pull_request) Failing after 14m27s
feat(assets): a creature's picture is whichever action has one (Phase 6)
The shard's catalogue can now answer for 73 bodies it used to report absent —
they have no art at action 0 and real art at a later one, and their key says
which (`body/820/a23` is a horse). This side stores that action and stops
assuming `a0` anywhere.

The atlas join is the part that mattered. It read

  a.asset_key = CONCAT('body/', b.body, '/a0')

which would have silently dropped exactly the creatures this phase adds. It now
reads the row's own action, with COALESCE for rows written before the column
existed — a NULL inside CONCAT makes the whole comparison NULL, which would have
taken every portrait off the site on upgrade with the database perfectly correct
and nothing in any log. It still matches at most one row per slug: a deeper key
(`body/820/a23/f4`) does not equal the catalogue key.

Verified against a real MariaDB with the live shard's own 1,095-row manifest: the
ALTER applies to an installed-shape table and is idempotent, the horse joins to
its a23 picture, a pre-phase-6 NULL-action row keeps its portrait, and a stored
frame key does not become a second candidate.

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

511 lines
18 KiB
JavaScript

const fs = require('fs')
const path = require('path')
const db = require('./shardAssets.db')
const core = require('../../core')
const bridge = require('../../utils/assetBridge')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const log = require('../../core').logger('shardItemArt')
// Item and land pictures, fetched because something on this site names them
// (docs/link/v8.md §5, §11 — protocol 8, phase 5).
//
// ── Why this is not the body catalogue with a different prefix ─────────
//
// The bestiary wants every creature, so phase 3 imports a SET: walk a manifest,
// diff the hashes, fetch what moved. That works because the set is 1,095 rows
// and one megabyte.
//
// This side has no set. The shard's client addresses 49,152 item graphics and
// has art for 39,189 of them; multiply by three thousand hues and there is
// nothing to enumerate, no manifest worth building and nothing worth importing
// ahead of time. What there IS, at any moment, is a few hundred keys that the
// site's own rows actually name: the items on a vendor, the things a character
// is wearing. That is the working set, and it is what this fetches.
//
// ── Who is allowed to make the shard do work ──────────────────────────
//
// **Ingest warms; the route only serves** (org lead, 2026-09-11). A page never
// waits on the shard and never causes a fetch: it renders the pictures already
// on disk and leaves out the ones that are not, which is exactly the state every
// install was in before this phase and which every surface already handles.
// Fetching happens behind that, from the keys the site has stored.
//
// The alternative — fetch on the first request for a key — was rejected on one
// number. The shard's asset plane serves **one request at a time** by design
// (§3.2), so any anonymous visitor able to name a key could walk 49,152 ids
// times 3,000 hues through that single slot and keep an operator's own import
// waiting behind it, from a URL with nothing to authenticate. Warming from the
// site's own data has no such surface: the ceiling is the number of distinct
// (item, hue) pairs the shard itself has told us about.
//
// ── Why the wanted set is DERIVED and not a queue ─────────────────────
//
// A queue table would need writing on the ingest path, draining, retrying,
// pruning and reconciling after a restart. The same answer falls out of a
// `SELECT DISTINCT` over the rows that name the items — which is self-healing by
// construction: a key lost to a restart comes back the next time the pass runs,
// and a key for a vendor that has gone stops being wanted the moment its row is
// deleted. The in-memory set below is an optimisation on top of that, never the
// record: it exists so a picture seen on a LIVE character sheet — which is
// fetched from the shard per request and stored nowhere — is not forgotten.
//
// ── Staleness, without a manifest (§7) ────────────────────────────────
//
// Every fetched row records the shard's `catalog` id, which is a hash of the
// files that decide the bytes. A client patch changes it, a restart does not. So
// "is this picture out of date?" is a per-row comparison rather than a manifest
// diff, and the answer costs nothing for the pictures nobody is looking at any
// more: they are simply never re-fetched.
/** Where item and land pictures land, under core's upload directory. */
const ART_SUBDIR = 'items'
/**
* How many keys one warm pass will fetch.
*
* A bound rather than a target. The shard serves one asset request at a time, so
* a pass that asked for everything at once would hold that slot for as long as it
* took — against an operator who might be trying to run an import. Passes are
* cheap and repeat; a backlog drains over several of them and nothing waits.
*/
const WARM_BATCH = 400
/**
* How many live-observed keys are remembered between passes.
*
* Bounded because this is a set fed by page views. It is an optimisation over the
* derived set, so dropping from it costs a picture appearing one pass later, and
* never a picture that is lost.
*/
const SEEN_CAP = 5000
const seen = new Set()
// ── keys (§5) ──────────────────────────────────────────────────────────────
/**
* The one place an item key is spelled.
*
* Hue 0 means "not hued" on the wire, so it produces the plain key rather than a
* `/h0` one — the shard refuses `/h0` outright for the same reason, and the two
* agreeing is what stops the same PNG being stored twice under two names.
*/
function staticKey(itemId, hue = 0) {
const id = Number(itemId)
if (!Number.isInteger(id) || id < 0) return null
const h = Number(hue)
return Number.isInteger(h) && h > 0 ? `static/${id}/h${h}` : `static/${id}`
}
function landKey(tileId) {
const id = Number(tileId)
return Number.isInteger(id) && id >= 0 && id < 0x4000 ? `land/${id}` : null
}
function artDir() {
return path.join(core.uploads.UPLOAD_DIR, ART_SUBDIR)
}
/**
* Content-addressed, exactly as the body catalogue's names are and for the same
* reason: a stable name overwritten in place leaves every browser and CDN serving
* last month's client's sprite while the database row stays perfectly correct.
*/
function fileNameFor(key, sha256) {
const stem = key.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return `uo-${stem}-${String(sha256).slice(0, 8)}.png`
}
// ── noticing ───────────────────────────────────────────────────────────────
/**
* Remember that something on this site showed these (itemId, hue) pairs.
*
* Called from the market ingest and from the character sheet, and deliberately
* synchronous and allocation-light: it is on a request path and a page must never
* pay for a picture it is not going to get anyway.
*/
function notice(items) {
if (!Array.isArray(items)) return 0
let added = 0
for (const item of items) {
const key = staticKey(item?.itemId ?? item?.item_id, item?.hue)
if (!key || seen.has(key)) continue
// Oldest-first, and only when full. The derived set is the record; this is a
// cache of hints, so forgetting one costs a pass, not a picture.
if (seen.size >= SEEN_CAP) seen.delete(seen.values().next().value)
seen.add(key)
added++
}
return added
}
/** For tests and the admin surface: how many hints are waiting. */
function noticedCount() {
return seen.size
}
// ── serving ────────────────────────────────────────────────────────────────
/**
* Attach `art` to rows that name an item, in place, and notice what is missing.
*
* `art` is a FILENAME under `uploads/items/`, never a path or a URL — the same
* shape `shard_spawn_creatures.art` uses, so the client builds one URL the same
* way everywhere and the API never hard-codes a mount point.
*
* A row with no stored picture gets `art: null` rather than being changed in any
* other way. That is a first-class state: it is what every row looked like before
* this phase, every surface renders it, and it is what an item this client has no
* art for looks like permanently.
*/
async function decorate(rows, { itemIdField = 'itemId', hueField = 'hue' } = {}) {
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r === 'object') : []
if (list.length === 0) return list
const keys = list.map((row) => staticKey(row[itemIdField], row[hueField]))
let files = new Map()
try {
files = await db.filesForKeys(keys.filter(Boolean))
} catch (err) {
// Decoration, not the page. A picture lookup that fails must not fail a
// marketplace search.
log.warn('could not read item art', { error: err.message })
return list
}
for (let i = 0; i < list.length; i++) {
list[i].art = (keys[i] && files.get(keys[i])) || null
}
// Everything this page WANTED is worth warming, whether or not we had it: the
// ones we had may be stale, and the ones we did not are the point.
notice(list.map((row) => ({ itemId: row[itemIdField], hue: row[hueField] })))
return list
}
// ── warming ────────────────────────────────────────────────────────────────
async function shardLinked() {
try {
const config = await uoLinkConfig.getSafe()
return Boolean(config?.enabled && config?.baseUrl)
} catch {
return false
}
}
/**
* Every item key the site's own rows name, newest-priced first.
*
* `shard_vendor_items` is the only stored table that carries (item_id, hue)
* today. The character sheet's equipment is fetched live from the shard per
* request and stored nowhere, which is precisely what the in-memory hint set is
* for.
*/
async function wantedKeys() {
const keys = []
try {
const rows = await core.query(
'SELECT DISTINCT item_id, hue FROM shard_vendor_items WHERE item_id > 0 LIMIT 20000',
)
for (const row of rows) {
const key = staticKey(row.item_id, row.hue)
if (key) keys.push(key)
}
} catch (err) {
log.warn('could not read the marketplace for item art', { error: err.message })
}
// Hints last, so a backlog of stored rows is never starved by page traffic.
for (const key of seen) keys.push(key)
return [...new Set(keys)]
}
function writePicture(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 item picture', { key, error: err.message })
return null
}
}
function removePicture(name) {
if (!name) return
try {
fs.unlinkSync(path.join(artDir(), name))
} catch {
// Already gone, or never written. A warm pass must not fail because a file it
// was tidying up was tidied already.
}
}
/**
* One warm pass: fetch the wanted keys we do not already hold, and store them.
*
* Returns a result rather than throwing, with the same vocabulary the body import
* uses — `skipped`, `unavailable`, `unchanged`, `imported`, `failed` — so the
* admin surface reports one set of words for both halves of this protocol.
*
* `limit` bounds one pass. `force` re-fetches keys we hold, which is how an
* operator recovers from a wiped uploads volume without waiting for a client
* patch to invalidate every row.
*/
async function warm({ limit = WARM_BATCH, force = false } = {}) {
if (!(await shardLinked())) {
return { status: 'skipped', reason: 'uo-link is not configured, so there is no shard to ask' }
}
let sources
try {
sources = await bridge.sourceFingerprint()
} catch (err) {
return failure(err, 'client file manifest')
}
if (sources.imaging && sources.imaging.ok === false) {
return {
status: 'unavailable',
code: 'NO_IMAGING',
reason: sources.imaging.reason || 'the shard host cannot render images',
}
}
// A phase-3 or phase-4 overlay serves bodies and nothing else. Asking it for a
// static is refused per request, which would be a warn on every pass forever —
// so it is checked once, here, and reported as the ordinary state it is.
// Defensive default rather than a trusted field: an older sidecar, an older
// overlay or a stubbed fingerprint can all leave it off, and `['body']` is the
// truthful reading of its absence (§6 — the families field arrived in phase 5).
const families = Array.isArray(sources.families) ? sources.families : ['body']
if (!families.includes('static')) {
return {
status: 'unavailable',
code: 'UNSUPPORTED',
reason:
"this shard's overlay does not serve item art; it offers " +
`${families.join(', ')}. Update the plugin overlay to get it.`,
}
}
const wanted = await wantedKeys()
if (wanted.length === 0) {
return { status: 'unchanged', wanted: 0, fetched: 0, written: 0 }
}
// The catalogue is learned from the first reply rather than asked for, so this
// pass cannot be the thing that decides what is stale. `catalog: null` on the
// request means "whatever you have"; the mid-walk guard in `fetchAssets` is what
// catches a client that moves underneath it.
let held = new Set()
if (!force) {
const current = await currentCatalog()
try {
held = await db.freshKeys(wanted, current)
} catch (err) {
return { status: 'failed', reason: err.message }
}
}
const todo = wanted.filter((key) => !held.has(key)).slice(0, Math.max(1, limit))
if (todo.length === 0) {
forget(wanted)
return { status: 'unchanged', wanted: wanted.length, held: held.size, fetched: 0, written: 0 }
}
let fetched
try {
fetched = await bridge.fetchAssets({ keys: todo })
} catch (err) {
return failure(err, 'item art')
}
// Only the filenames, and only for the keys in hand: the old file is removed
// when a key's hash moves, so `uploads/items/` tracks the working set instead of
// accumulating one file per client patch forever.
const existing = await db.filesForKeys(todo).catch(() => new Map())
const rows = []
let written = 0
for (const key of todo) {
const got = fetched.assets.get(key)
// A key the shard has no art for is not a failure and not a row: writing an
// empty row would make it "held" and stop it ever being asked again, which is
// wrong the moment an operator patches in the missing graphic.
if (!got) continue
const name = writePicture(key, got.sha256, got.png)
if (!name) continue
written++
const before = existing.get(key)
if (before && before !== name) removePicture(before)
rows.push({
key,
family: key.startsWith('land/') ? 'land' : 'static',
sha256: got.sha256,
bytes: got.bytes,
width: got.width,
height: got.height,
body: null,
direction: null,
file: name,
catalog: fetched.catalog,
})
}
if (rows.length > 0) {
try {
// No meta: `shard_asset_meta` is the BODY catalogue's singleton — what an
// Update compares a manifest against — and this family has no manifest. A
// warm pass writing there would tell the body import that a client it never
// looked at is unchanged.
await db.saveAssets(rows, null)
} catch (err) {
return { status: 'failed', reason: err.message }
}
}
forget(todo)
const result = {
status: 'imported',
catalog: fetched.catalog,
wanted: wanted.length,
held: held.size,
asked: todo.length,
fetched: fetched.assets.size,
written,
absent: fetched.missing.absent,
unsupported: fetched.missing.unsupported,
remaining: Math.max(0, wanted.length - held.size - todo.length),
}
log.info('item art warmed', result)
return result
}
/** Drop hints a pass has dealt with, so the set does not grow without bound. */
function forget(keys) {
for (const key of keys) seen.delete(key)
}
/**
* The catalogue id the shard would answer under right now.
*
* Read from a one-key probe rather than from a dedicated command: the shard puts
* `catalog` on every fetch reply, so the cheapest honest way to ask is to fetch
* something. `static/0` is the smallest such question and its answer is thrown
* away — what is wanted is the id beside it.
*
* A shard that cannot answer returns null, and null compares unequal to every
* stored catalogue, so the pass falls back to "everything is stale" — which costs
* a re-fetch and never serves a wrong picture. That is the right way round.
*/
async function currentCatalog() {
try {
const probe = await bridge.fetchAssets({ keys: ['static/0'] })
return probe.catalog ?? null
} catch (err) {
log.warn('could not read the shard art catalogue', { error: err.message })
return null
}
}
function failure(err, what) {
if (err instanceof bridge.AssetBridgeError) {
return { status: 'unavailable', code: err.code, reason: err.message }
}
log.warn(`item art failed reading the ${what}`, { error: err.message })
return { status: 'failed', reason: err.message }
}
// ── the background pass ────────────────────────────────────────────────────
let timer = null
/**
* Run a warm pass every few minutes, forever, while the process lives.
*
* Deliberately a plain interval and not a debounce on ingest. A market sweep
* delivers dozens of `vendor.listing` frames in a burst and debouncing each of
* them would either fire once per frame or need its own state machine; a pass is
* cheap when there is nothing to do (one `SELECT DISTINCT` and one probe) and the
* work it exists for is not urgent — a picture appearing a few minutes after the
* listing that wants it is invisible to everyone.
*
* `unref()` so this never holds the process open at shutdown.
*/
function startWarming({ everyMs = 5 * 60 * 1000 } = {}) {
if (timer) return
timer = setInterval(() => {
warm().catch((err) => log.warn('item art warm pass failed', { error: err.message }))
}, everyMs)
if (typeof timer.unref === 'function') timer.unref()
}
function stopWarming() {
if (!timer) return
clearInterval(timer)
timer = null
}
module.exports = {
ART_SUBDIR,
WARM_BATCH,
artDir,
fileNameFor,
staticKey,
landKey,
notice,
noticedCount,
decorate,
wantedKeys,
warm,
currentCatalog,
startWarming,
stopWarming,
}