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 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, }) } 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, ]) await batched( conn, 'INSERT INTO shard_assets (asset_key, family, sha256, bytes, width, height, body, direction, file) ' + '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), ' + '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() } } 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, }