Both places this site already knew an item's (ItemID, hue) and could only print it as text now show the picture, hued the way the client would draw it. The shard does the hueing: whether a hue repaints every pixel or only the grey ones is a flag in `tiledata.mul`, which a browser has no way to read. **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 what is stored and leaves out what is not, which is the state every install was in before this phase. Fetching happens behind that, on a timer, from the keys the site's own rows name. The alternative, fetching on first request, was rejected on one number: the shard's asset plane serves ONE request at a time, so a URL that fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot and park an operator's own import behind it. The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so it is self-healing: a restart loses nothing, and a key stops being wanted the moment the vendor row naming it is deleted. The in-memory hint set on top is only for the character sheet, which is fetched live from the shard and stored nowhere -- nothing on disk would ever name those keys. Staleness without a manifest (§7): every row records the shard's `catalog` id, a hash of the files that decide its bytes. A client patch changes it and a restart does not, so "is this out of date?" is a per-row question -- and pictures nobody looks at any more are simply never re-fetched, which is why this is lazy rather than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body catalogue's singleton, and a warm pass touching it would tell the body import that a client it never looked at is unchanged. A key the shard has no art for writes no row at all. An empty row would make the key held and it would never be asked again -- including after the operator patches in the graphic that was missing. `assets.sources` now reports which families an overlay serves, so an overlay older than phase 5 is one reported state with a sentence naming the fix, instead of a refusal per pass forever with no picture ever appearing. 688 server tests pass (14 new); client builds; the frozen manifest regenerates with one added route, all documented, no core URL moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
297 lines
10 KiB
JavaScript
297 lines
10 KiB
JavaScript
const core = require('../../core')
|
|
|
|
const { query } = core
|
|
|
|
// Raw SQL for the Asset Bridge's three tables (docs/link/v8.md §6, §8, §12).
|
|
//
|
|
// Unlike `shard_clilocs` and the atlas tables, these are NOT import-owned in the
|
|
// empty-and-refill sense, and the difference is the whole reason phase 3 put them
|
|
// in their own tables rather than in columns on `shard_spawn_creatures`.
|
|
//
|
|
// An asset row is expensive to obtain — a decode on the shard, a PNG across the
|
|
// wire, a file written under uploads/ — and it is valid until the operator
|
|
// patches their client. An atlas refresh, by contrast, happens on every boot and
|
|
// destroys everything it owns. Putting the two in one table would mean a routine
|
|
// re-parse of the ServUO tree silently deleting every imported portrait, with the
|
|
// next Update reporting "nothing changed" and never restoring them.
|
|
//
|
|
// So these are upserted per key, and the only thing that ever deletes from them
|
|
// is an explicit removal of a key the shard no longer offers — which is staged
|
|
// for review, never applied silently (§6).
|
|
|
|
const BATCH = 500
|
|
|
|
async function batched(conn, sql, rows) {
|
|
for (let i = 0; i < rows.length; i += BATCH) {
|
|
await conn.batch(sql, rows.slice(i, i + BATCH))
|
|
}
|
|
return rows.length
|
|
}
|
|
|
|
// ── the manifest side ──────────────────────────────────────────────────────
|
|
|
|
/** Every asset row we hold, as a Map of key → row. */
|
|
async function allAssets() {
|
|
const rows = await query(
|
|
'SELECT asset_key, family, sha256, bytes, width, height, body, direction, file, catalog FROM shard_assets',
|
|
)
|
|
|
|
const map = new Map()
|
|
|
|
for (const row of rows) {
|
|
map.set(row.asset_key, {
|
|
key: row.asset_key,
|
|
family: row.family,
|
|
sha256: row.sha256,
|
|
bytes: Number(row.bytes) || 0,
|
|
width: Number(row.width) || 0,
|
|
height: Number(row.height) || 0,
|
|
body: row.body === null ? null : Number(row.body),
|
|
direction: row.direction === null ? null : Number(row.direction),
|
|
file: row.file || null,
|
|
catalog: row.catalog || null,
|
|
})
|
|
}
|
|
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Write the assets an import produced, and record what the import was.
|
|
*
|
|
* One transaction for the rows and the meta together: the meta row is what an
|
|
* Update compares against to decide there is nothing to do, so a meta written
|
|
* without its rows would make the site believe it holds a catalogue it does not.
|
|
*
|
|
* `ON DUPLICATE KEY UPDATE` rather than delete-and-insert, because an unchanged
|
|
* key must keep the file it already points at — re-writing the file for every
|
|
* asset on every Update is exactly the cost the manifest diff exists to avoid.
|
|
*/
|
|
async function saveAssets(rows, meta) {
|
|
const conn = await core.pool.getConnection()
|
|
|
|
try {
|
|
await conn.beginTransaction()
|
|
|
|
const values = rows.map((r) => [
|
|
r.key,
|
|
r.family || 'body',
|
|
r.sha256,
|
|
r.bytes ?? 0,
|
|
r.width ?? 0,
|
|
r.height ?? 0,
|
|
r.body ?? null,
|
|
r.direction ?? null,
|
|
r.file ?? null,
|
|
r.catalog ?? meta?.catalog ?? null,
|
|
])
|
|
|
|
await batched(
|
|
conn,
|
|
'INSERT INTO shard_assets (asset_key, family, sha256, bytes, width, height, body, direction, file, catalog) ' +
|
|
'VALUES (?,?,?,?,?,?,?,?,?,?) ' +
|
|
'ON DUPLICATE KEY UPDATE family = VALUES(family), sha256 = VALUES(sha256), ' +
|
|
'bytes = VALUES(bytes), width = VALUES(width), height = VALUES(height), ' +
|
|
'body = VALUES(body), direction = VALUES(direction), file = VALUES(file), ' +
|
|
'catalog = VALUES(catalog), imported_at = CURRENT_TIMESTAMP',
|
|
values,
|
|
)
|
|
|
|
if (meta) {
|
|
await conn.query(
|
|
'INSERT INTO shard_asset_meta (id, payload) VALUES (1, ?) ' +
|
|
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
|
[JSON.stringify(meta)],
|
|
)
|
|
}
|
|
|
|
await conn.commit()
|
|
|
|
return values.length
|
|
} catch (err) {
|
|
await conn.rollback().catch(() => {})
|
|
throw err
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
// ── the on-demand side (§11, phase 5) ──────────────────────────────────────
|
|
|
|
/**
|
|
* The pictures we hold for an explicit list of keys, as a Map of key → filename.
|
|
*
|
|
* This is the read on the hot path — every marketplace page and every character
|
|
* sheet runs it — so it is one statement over the primary key and it returns only
|
|
* what it is asked for. It deliberately does NOT check staleness: a page renders
|
|
* the picture it has, and deciding whether that picture is out of date is the warm
|
|
* pass's job, off the request.
|
|
*/
|
|
async function filesForKeys(keys) {
|
|
const list = [...new Set(keys.filter((k) => typeof k === 'string' && k !== ''))]
|
|
|
|
if (list.length === 0) return new Map()
|
|
|
|
const rows = await query(
|
|
`SELECT asset_key, file FROM shard_assets WHERE file IS NOT NULL AND asset_key IN (${list
|
|
.map(() => '?')
|
|
.join(',')})`,
|
|
list,
|
|
)
|
|
|
|
const map = new Map()
|
|
|
|
for (const row of rows) map.set(row.asset_key, row.file)
|
|
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Which of these keys we already hold under the shard's CURRENT catalogue.
|
|
*
|
|
* The warm pass subtracts this from what it wants, so everything it does not
|
|
* return gets fetched: a key we have never seen, and a key whose row was written
|
|
* against a catalogue the shard has since moved past (§7 — an operator patched
|
|
* their client). A row with no file is not held either, because the database and
|
|
* the uploads volume can disagree and a broken image is worse than a re-fetch.
|
|
*/
|
|
async function freshKeys(keys, catalog) {
|
|
const list = [...new Set(keys.filter((k) => typeof k === 'string' && k !== ''))]
|
|
|
|
if (list.length === 0) return new Set()
|
|
|
|
const rows = await query(
|
|
`SELECT asset_key FROM shard_assets WHERE file IS NOT NULL AND catalog <=> ? ` +
|
|
`AND asset_key IN (${list.map(() => '?').join(',')})`,
|
|
[catalog ?? null, ...list],
|
|
)
|
|
|
|
return new Set(rows.map((r) => r.asset_key))
|
|
}
|
|
|
|
/** Counts for the admin surface, split by family. */
|
|
async function countByFamily() {
|
|
const rows = await query(
|
|
'SELECT family, COUNT(*) AS total, SUM(file IS NOT NULL) AS stored FROM shard_assets GROUP BY family',
|
|
)
|
|
|
|
const out = {}
|
|
|
|
for (const row of rows) {
|
|
out[row.family] = { total: Number(row.total) || 0, stored: Number(row.stored) || 0 }
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
async function getMeta() {
|
|
const rows = await query('SELECT payload, imported_at FROM shard_asset_meta WHERE id = 1')
|
|
if (rows.length === 0) return null
|
|
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
|
return { ...payload, importedAt: rows[0].imported_at }
|
|
}
|
|
|
|
async function countAssets() {
|
|
const rows = await query(
|
|
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets',
|
|
)
|
|
return { total: Number(rows[0]?.n) || 0, stored: Number(rows[0]?.stored) || 0 }
|
|
}
|
|
|
|
// ── the body resolution side (§8) ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Replace the whole slug → body map.
|
|
*
|
|
* This one IS a replace, and for the opposite reason to the assets above: it is
|
|
* derived from the atlas's creature list, so a slug that has left the atlas has
|
|
* no meaning any more and keeping its row would leave the map growing forever
|
|
* across map changes. The pass that produces it is cheap to redo — a shard round
|
|
* trip, no files — which is what makes replacing safe here and not there.
|
|
*/
|
|
async function replaceBodies(rows) {
|
|
const conn = await core.pool.getConnection()
|
|
|
|
try {
|
|
await conn.beginTransaction()
|
|
await conn.query('DELETE FROM shard_creature_bodies')
|
|
|
|
const values = rows.map((r) => [r.slug, r.typeName, r.body ?? null, r.status || 'ok'])
|
|
|
|
await batched(
|
|
conn,
|
|
'INSERT INTO shard_creature_bodies (slug, type_name, body, status) VALUES (?,?,?,?)',
|
|
values,
|
|
)
|
|
|
|
await conn.commit()
|
|
|
|
return values.length
|
|
} catch (err) {
|
|
await conn.rollback().catch(() => {})
|
|
throw err
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
async function allBodies() {
|
|
return query(
|
|
'SELECT slug, type_name, body, status, resolved_at FROM shard_creature_bodies ORDER BY slug',
|
|
)
|
|
}
|
|
|
|
async function countBodies() {
|
|
const rows = await query(
|
|
"SELECT COUNT(*) AS n, SUM(status = 'ok') AS resolved FROM shard_creature_bodies",
|
|
)
|
|
return { total: Number(rows[0]?.n) || 0, resolved: Number(rows[0]?.resolved) || 0 }
|
|
}
|
|
|
|
/**
|
|
* The derivation `replaceAtlas` applies on the way past: slug → uploaded filename.
|
|
*
|
|
* One join rather than two reads, because it runs inside the atlas transaction —
|
|
* the atlas rows are being inserted at that moment and every extra round trip is
|
|
* time the site's creature list does not exist.
|
|
*
|
|
* Rows with no body, no asset or an asset whose bytes were never fetched are
|
|
* simply absent from the result, which is what leaves `art` NULL. That is a
|
|
* first-class state everywhere it is consumed and the expected one for two thirds
|
|
* of the player bodies (§5.2).
|
|
*
|
|
* **The join is pinned to the catalogue key, not merely to the body id.** Today
|
|
* one body has exactly one asset, so `a.body = b.body` alone would be correct —
|
|
* and it would stop being correct the moment phase 6 adds `body/400/a2/f0`, at
|
|
* which point one slug would match dozens of rows and whichever the engine
|
|
* returned last would become the portrait. Naming the key here means that phase
|
|
* adds rows without changing what a creature page shows.
|
|
*/
|
|
async function artBySlug() {
|
|
const rows = await query(
|
|
'SELECT b.slug, a.file FROM shard_creature_bodies b ' +
|
|
"JOIN shard_assets a ON a.asset_key = CONCAT('body/', b.body, '/a0') " +
|
|
"WHERE b.status = 'ok' AND b.body IS NOT NULL AND a.file IS NOT NULL",
|
|
)
|
|
|
|
const map = {}
|
|
|
|
for (const row of rows) map[row.slug] = row.file
|
|
|
|
return map
|
|
}
|
|
|
|
module.exports = {
|
|
allAssets,
|
|
saveAssets,
|
|
getMeta,
|
|
countAssets,
|
|
replaceBodies,
|
|
allBodies,
|
|
countBodies,
|
|
artBySlug,
|
|
filesForKeys,
|
|
freshKeys,
|
|
countByFamily,
|
|
}
|