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
137 lines
5.6 KiB
JavaScript
137 lines
5.6 KiB
JavaScript
// ── Admin · Client assets ──────────────────────────────────────────────────
|
|
//
|
|
// Operating the asset import: what the site holds, what the shard's client files
|
|
// currently are, and a re-import after a client patch (docs/link/v8.md §6, §8,
|
|
// §14, protocol 8 phase 3).
|
|
//
|
|
// The policy lives in the model. This controller does three things and no more:
|
|
// it validates input, it maps an import RESULT onto an HTTP status, and it
|
|
// records the action in the admin activity log.
|
|
//
|
|
// **An import result is not an exception**, exactly as for clilocs. A shard that
|
|
// is down, an asset plane the operator has switched off, a Linux host with no
|
|
// libgdiplus, a client patched halfway through the walk — each is a 200 carrying
|
|
// `status: 'unavailable'` and a reason naming what to fix, not a 500 that says
|
|
// only "something broke". The one thing that DOES 500 is this file having a bug.
|
|
//
|
|
// **This is the only thing that imports.** Boot never calls the shard for assets,
|
|
// for the same reason it stopped calling it for clilocs: the files change when an
|
|
// operator patches their client, which is an event they know about and the site
|
|
// does not. So this endpoint is what an operator presses afterwards.
|
|
//
|
|
// The full panel — per-key review, the activity view, approve/reject as buttons —
|
|
// is phase 8. This pair is what makes phase 3 reachable at all.
|
|
|
|
const assets = require('../../model/shardAssets/shardAssets.model')
|
|
const itemArt = require('../../model/shardAssets/shardItemArt.model')
|
|
const { activity } = require('../../core')
|
|
|
|
const log = require('../../core').logger('admin-shard-assets')
|
|
|
|
// GET /admin/shard/assets — what is loaded, what the shard says, whether they
|
|
// disagree. No public counterpart: the assets themselves are served as ordinary
|
|
// files under /uploads, and this is the operating view of the import.
|
|
async function getStatus(req, res) {
|
|
try {
|
|
return res.json(await assets.getStatus())
|
|
} catch (err) {
|
|
log.error('getStatus', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /admin/shard/assets/import — import or update the body catalogue, then
|
|
// re-resolve the atlas's creatures and re-derive their artwork.
|
|
//
|
|
// `force` re-imports even when the client files are unchanged. It is also how an
|
|
// operator recovers a wiped uploads volume: the database still holds every hash,
|
|
// so the ordinary gate would report "unchanged" while every picture is missing.
|
|
// (The import checks for the file on disk per key as well, so that case usually
|
|
// heals itself — `force` is the answer when it does not.)
|
|
//
|
|
// `approve` accepts a catalogue that no longer offers keys this site holds.
|
|
// Refused by default because an unmounted client volume and a deliberate
|
|
// downgrade look identical from the server, and the wrong guess deletes artwork.
|
|
async function importAssets(req, res) {
|
|
try {
|
|
const force = !!req.body?.force
|
|
const approve = !!req.body?.approve
|
|
const result = await assets.importAssets({ force, approve })
|
|
|
|
await activity.log({
|
|
req,
|
|
action: 'shard.assets.import',
|
|
detail: {
|
|
force,
|
|
approve,
|
|
status: result.status,
|
|
code: result.code ?? null,
|
|
assets: result.assets ?? null,
|
|
fetched: result.fetched ?? null,
|
|
written: result.written ?? null,
|
|
removed: result.removed ?? null,
|
|
// The body pass is logged as its own tally rather than as a single
|
|
// number: `unknown` means the spawn files name a type this shard's
|
|
// scripts do not define, which is real drift an operator should see, and
|
|
// it reads identically to `failed` if both are summed into "not resolved".
|
|
bodies: result.bodies?.tally ?? null,
|
|
vanished: result.vanishedCount ?? null,
|
|
},
|
|
})
|
|
|
|
return res.json(result)
|
|
} catch (err) {
|
|
log.error('importAssets', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /admin/shard/assets/warm — run one item-art warm pass now.
|
|
//
|
|
// The pass runs on its own timer and needs no operator, so this exists for the
|
|
// two moments where waiting for the interval is the wrong answer: an operator who
|
|
// has just configured the bridge and wants to see it work, and one who has just
|
|
// patched their client and would rather not wait for pictures to refresh.
|
|
//
|
|
// `force` re-fetches keys the site already holds. The body import's `force` means
|
|
// the same thing for the same reason — a wiped uploads volume leaves every
|
|
// database row correct and every picture missing, and only an explicit re-fetch
|
|
// recovers it.
|
|
//
|
|
// It is bounded: one pass asks for at most `limit` keys, because the shard's
|
|
// asset plane serves one request at a time and a pass must not hold that slot
|
|
// against the operator's own import.
|
|
async function warmItemArt(req, res) {
|
|
try {
|
|
const force = !!req.body?.force
|
|
const limit = Number.isFinite(Number(req.body?.limit)) ? Number(req.body.limit) : undefined
|
|
const result = await itemArt.warm({ force, ...(limit ? { limit } : {}) })
|
|
|
|
await activity.log({
|
|
req,
|
|
action: 'shard.assets.warm',
|
|
detail: {
|
|
force,
|
|
limit: limit ?? null,
|
|
status: result.status,
|
|
code: result.code ?? null,
|
|
wanted: result.wanted ?? null,
|
|
asked: result.asked ?? null,
|
|
written: result.written ?? null,
|
|
remaining: result.remaining ?? null,
|
|
},
|
|
})
|
|
|
|
return res.json(result)
|
|
} catch (err) {
|
|
log.error('warmItemArt', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getStatus,
|
|
importAssets,
|
|
warmItemArt,
|
|
}
|