feat(assets): item pictures on the marketplace and the character sheet (Phase 5)
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m23s

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
This commit is contained in:
2026-09-11 06:13:08 -05:00
parent 6ece48f7d3
commit f335531538
18 changed files with 1712 additions and 10 deletions

View File

@@ -399,6 +399,22 @@ shardRouter.post(
shardAssets.importAssets,
)
shardRouter.post(
'/assets/warm',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Fetch item and land artwork the site is missing, now (admin only)'
// #swagger.description = 'Runs one pass of the item-art warm loop instead of waiting for its timer. The pass works out which item pictures this site's own rows name — every distinct (ItemID, hue) on a player vendor, plus anything a character sheet has shown since the last pass — and fetches the ones it does not already hold from the shard, hued and stored under uploads/items/. There is deliberately NO manifest and no bulk import here: the client addresses 49,152 item graphics times three thousand hues, so the working set is defined by what the site actually displays. `force` re-fetches pictures the site already holds, which is how an operator recovers a wiped uploads volume. `limit` bounds one pass; the default is 400, because the shard serves one asset request at a time and a pass must not hold that slot against an import. Nothing throws for an operator-visible problem: no shard configured, a shard that is down, an asset plane switched off, a host with no libgdiplus, or a plugin overlay too old to serve item art all answer 200 with status "unavailable"/"skipped" and a reason naming what to fix.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Re-fetch pictures this site already holds." }, limit: { type: "integer", description: "How many keys this pass may fetch (1-2000)." } } } } } } */
/* #swagger.responses[200] = { description: 'What the pass did', content: { "application/json": { schema: { $ref: "#/components/schemas/UoItemArtWarmResult" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('force').optional().isBoolean(),
body('limit').optional().isInt({ min: 1, max: 2000 }),
validate,
shardAssets.warmItemArt,
)
// ── Feature visibility (admin only) ───────────────────────────────────
// Who can see which shard surface, and which sensitive fields within it. This
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.

View File

@@ -23,6 +23,7 @@
// 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')
@@ -85,7 +86,51 @@ async function importAssets(req, res) {
}
}
// 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,
}