Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.
The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.
Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.
The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:
* The body import diffed its manifest against every family's rows. Phase 5 put
item and land art in the same table, and a body manifest never mentions
them, so all 313 item pictures were staged for deletion with a sentence
saying the shard had stopped offering them.
* An approved vanish unlinked the sprite and kept the row. The catalogue went
on counting a picture that was gone, the atlas could point a creature page at
a missing file, and the next forced import offered the same key for review
again -- reporting "nothing was changed" about a file it had deleted.
Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.
Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.
Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
144 lines
6.2 KiB
JavaScript
144 lines
6.2 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.
|
|
//
|
|
// Phase 8 built the panel these serve (`Admin → Client Files`) and added one
|
|
// thing to this pair: the import records a summary of what it did, and the
|
|
// vanished keys it refuses to apply come back with the pictures they currently
|
|
// have. Both exist because an operator pressing Update needs to see an answer,
|
|
// and the audit log — which still receives every action here — is one unfiltered
|
|
// list of every admin action on the site, so an import from three client patches
|
|
// ago cannot be found in it (org lead, 2026-09-14).
|
|
|
|
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
|
|
// From the session, never the body — the same rule the in-game ops routes
|
|
// apply, and for the same reason: this is recorded as who did it.
|
|
const result = await assets.importAssets({ force, approve, by: req.user?.username ?? null })
|
|
|
|
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,
|
|
}
|