// ── 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 } }), }, // 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