Files
Module-uo/client/src/api.js
wtclaude 675e879b48
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m4s
PR Checks / client-build (pull_request) Failing after 14m21s
feat(assets): the panel that operates the client-file imports (Phase 8)
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
2026-09-14 08:10:16 -05:00

265 lines
14 KiB
JavaScript

// ── This module's own API bindings ─────────────────────────────────────────
//
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, `ApiError` on
// a non-2xx. The paths are ours, because the routes at the other end are ours —
// `server/router/**` in this repo serves every one of them.
//
// This file is the client half of the pair that moved in slice 1, and the two
// halves are checked against each other by nothing but review, so the ordering
// below mirrors the router tree deliberately: public, then admin, then player.
//
// **The URLs are unchanged from the ones core used to call.** MODULE_SYSTEM.md
// §1.2 freezes the API surface across the extraction — the shipped Android app
// calls `/api/v1/admin/shard/kick` and six of its neighbours — so what moved is
// which repo declares them, never what they are. Only the SPA route paths
// changed (`/uo/*`, `/admin/uo/*`, `/player/uo/*`), and those are not API URLs.
import rg from './core.js'
const { request: req, BASE } = rg.api
/** Prefix a non-empty query string with "?" — core's `withQs`, which is not in the kit. */
const withQs = (s) => (s ? `?${s}` : '')
// ── public: live shard data (uo-link) ──────────────────────────────────────
// Token-free, same-origin reads backed by the ingested feed plus a cached live
// character round-trip.
export const shard = {
status: () => req('/public/shard/status'),
feed: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit)
return req(`/public/shard/feed${withQs(qs.toString())}`)
},
economy: (limit) => req(`/public/shard/economy${withQs(limit ? `limit=${limit}` : '')}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
// Protocol 3.0: the shard's published ruleset. Resolves to null when the shard
// has never published one — a real answer, not an error.
ruleset: () => req('/public/shard/ruleset'),
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
// `pointsBoard` 404s for a system the shard has never published.
points: () => req('/public/shard/points'),
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
// the page debounces its search box rather than firing per keystroke.
market: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
if (opts.map) qs.set('map', opts.map)
if (opts.region) qs.set('region', opts.region)
if (opts.sort) qs.set('sort', opts.sort)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market${withQs(qs.toString())}`)
},
marketMeta: () => req('/public/shard/market/meta'),
marketVendor: (serial, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
},
// Which shard surfaces this caller may reach, plus the audience rung they
// resolved to. Drives nav so we never render a link that would 403 — and, as
// of slice 3, also carries `gameAccountSignup`: whether this site offers
// game-account creation at all (see server/router/public/shard.controller.js).
features: () => req('/public/shard/features'),
}
// ── public: the spawn atlas (Protocol 3.0 Part C) ──────────────────────────
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
// not under /shard, because nothing here depends on the sidecar and the pages
// stay populated while the shard is offline.
export const atlas = {
creatures: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.facet) qs.set('facet', opts.facet)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
},
creature: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.points) qs.set('points', opts.points)
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
},
regions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/regions${withQs(qs.toString())}`)
},
landmarks: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
// The CONFIGURED altar roster, not the live board — see `shard.champs()` for
// "which spawn is on level 3 right now".
champions: (facet) =>
req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
meta: () => req('/public/atlas/meta'),
}
// ── admin ──────────────────────────────────────────────────────────────────
export const admin = {
// The account/character/house reads a staff member makes across the whole shard.
shard: {
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// The sidecar's own configuration and the town crier it drives.
getUoLinkConfig: () => req('/admin/uo-link/config'),
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Whether this site offers game-account creation, and in which direction.
// Core's Site Settings used to carry this; it is ours as of slice 3, because
// "the game server's own SignupMode must agree" is not a sentence core can own.
getSignupMode: () => req('/admin/uo-link/signup-mode'),
saveSignupMode: (mode) => req('/admin/uo-link/signup-mode', { method: 'PUT', body: { mode } }),
// Per-feature shard visibility: who may see which shard surface, and which
// sensitive fields within it. Admin only — it decides what ANONYMOUS visitors
// get. acct/webId are admin-only always and the API rejects any attempt to
// configure them.
getShardVisibility: () => req('/admin/shard/visibility'),
saveShardVisibility: (features) => req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
// The atlas re-derives itself from the ServUO tree on every boot; these are for
// applying a map change without a restart, and for the approve/reject decision
// on a refresh that would remove a facet.
atlas: {
status: () => req('/admin/shard/atlas'),
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
},
// The Asset Bridge (docs/link/v8.md §6, §14 — protocol 8 phase 8). Client
// artwork and the cliloc table both come off the operator's own UO client, over
// the same bridge, and boot deliberately never asks the shard for either — so
// these calls are the only thing that imports them, and the panel that makes
// them is where an operator goes after patching their client.
//
// `update` and `reimport` are §6's two stages rather than one call with a flag,
// because they cost wildly different things: an Update that finds the client
// files unchanged transfers nothing, and a re-import fetches every sprite in
// the catalogue. A checkbox spells that difference the same size as the button.
assets: {
status: () => req('/admin/shard/assets'),
update: (approve = false) =>
req('/admin/shard/assets/import', { method: 'POST', body: { approve } }),
reimport: (approve = false) =>
req('/admin/shard/assets/import', { method: 'POST', body: { force: true, approve } }),
// Item and land pictures, which arrive one at a time because a page asked for
// one. The pass runs on its own timer; this is for the operator who has just
// patched a client and would rather not wait for the interval.
warm: (force = false) => req('/admin/shard/assets/warm', { method: 'POST', body: { force } }),
},
clilocs: {
status: () => req('/admin/shard/clilocs'),
import: (opts = {}) =>
req('/admin/shard/clilocs/import', {
method: 'POST',
body: { force: !!opts.force, approve: !!opts.approve },
}),
setPath: (path) => req('/admin/shard/clilocs/path', { method: 'PUT', body: { path } }),
},
// In-game staff operations: write plane + support queue (admin/moderator).
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) => req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${withQs(limit ? `limit=${limit}` : '')}`),
},
/**
* One user's shard presence, for the `admin.users.detail` extension slot.
*
* A factory rather than a flat namespace because every call is scoped to the
* user whose page this is. The three that are NOT — roster, vendors, char —
* are keyed by an account or a serial the scoped calls just returned, and they
* are the same routes `admin.shard` uses; they are repeated here so the slot's
* components take one `scope` object and never reach for a second one.
*/
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/shard/accounts`),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req(`/admin/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
}
// ── player self-service ────────────────────────────────────────────────────
// Mirrors `admin.shard`, self-scoped: the server derives the caller from the
// session and never takes an account id from the client.
export const player = {
shard: {
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
}
// ── SSE endpoints ──────────────────────────────────────────────────────────
// Full paths including `/api/v1`, because `request` is fetch-only and an
// EventSource builds its own URL. `BASE` is core's — it owns where the API is
// mounted, and a module hardcoding `/api/v1` would be asserting something about
// core that core has not promised (MODULE_API.md §3.5).
//
// The admin stream carries every kind, including audit and cheat detection, and
// needs the staff session cookie.
export const shardStreamUrl = `${BASE}/public/shard/stream`
export const adminShardStreamUrl = `${BASE}/admin/uo-link/stream`
export const api = { shard, atlas, admin, player, shardStreamUrl, adminShardStreamUrl }
export default api