Files
Module-uo/server/utils/uoLinkClient.js
wtclaude 89be9d6a4e
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 30s
PR Checks / frozen-manifest (pull_request) Successful in 43s
feat(events): the five world verbs an author sees (Phase 12a)
`uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and
`uo.decor.place`, over protocol 7's one command family. Five actions because
five is what an author has; one `perform`/`revert`/`reconcile` because on the
wire they are one thing.

Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`,
`uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead,
2026-09-07). Core meters whatever dimensions a module declares and holds no UO
knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate
is priced in minutes rather than in gates: one standing all day and twelve
standing five minutes each are not the same imposition on a world.

`reconcile()` ASKS the shard, and is the one place in this file that must not
use `reconcileByBootId`. A crier line lives in shard memory, so a changed
`bootId` IS proof it is gone; a spawned creature is in the world SAVE and
survives the restart the stamp would report it lost by. Anything `world.owned`
does not list is gone -- safe only because the shard's registry and the objects
it describes are written by the same save.

Teardown reports `gone` as success and `refused` as failed. A creature a player
killed is the point of having spawned it, and a run that ended `incomplete`
because its event worked would be a report nobody could read. `refused` means
the shard denies this run ever owned the serial, so nothing will delete it
through this path and the row must land unresolved with a reason.

The atlas gains a decoration index, parsed from the shard's own
`Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree
nests two deep and a flat read would index a fraction of it while looking like
it worked. 313 distinct types. The decor verb resolves through it rather than
passing a type name through, which keeps the verb to this shard's own decoration
vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements
under 1992 different graphics, so a bare type name places the wrong thing.
`PARSER_VERSION` -> 3, so an already-imported tree is re-read.

Two things the build found in code that had already shipped:

`uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not
something the shard can build, because a creature is constructed from a ServUO
class name and `orc-brute` is not one. The atlas's `name` is the raw type token
from the spawn files, so the fix was to stop discarding the half that works.
Safe to change because Phase 12a is the source's first consumer; the file said
so when it shipped.

`uo.npc.place` could not be performed from its own required params. Both ends
refuse an oracle with neither a greeting nor a line, but both fields were
optional -- so a cross-field rule sat where no authoring form could render it.
The greeting is now `required`, which says the same thing in the contract
itself. Caught by the existing dry-run sweep, which is a better argument for
that test than anything written about it when it shipped.

605 tests pass. `swagger-fragment.json` is stale on `edge` already and this
phase adds no route, so it is left alone.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:52:15 -05:00

371 lines
18 KiB
JavaScript

// ── uo-link sidecar REST client ────────────────────────────────────────────
//
// Server-side HTTP client for the uo-link sidecar (the bridge to the ServUO
// shard). Same shape as botInternalClient: never throws — every call returns
// { ok, data, status, error } so an admin poll or a public page never 500s just
// because the sidecar/shard is down or restarting.
//
// The base URL + shared-secret token come from the DB-backed uoLinkConfig
// (admin-managed, encrypted at rest) — NOT env vars, and the token is NEVER sent
// to the browser. Every request carries `Authorization: Bearer <token>` and
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
// token on every call.
//
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
//
// The three write helpers the event engine drives take an optional
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
// is what makes retrying a world write safe — before it, a lost acknowledgement
// and a command that never applied were the same event seen from here.
//
// **A key is a function of the caller's unit of work, never of the attempt.** The
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
// carries the same key and a different step never collides with it. Passing a
// fresh value per call would satisfy the type and defeat the entire mechanism.
//
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
// second removal of a town-crier entry or a news article is a no-op the shard is
// already happy to perform — and the sidecar builds those commands from the path
// rather than from a body, so carrying one would be a protocol change bought for
// a guarantee that already holds.
//
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
// what leaves the admin screens (which send none, being driven by a human who can
// see whether the thing happened) unchanged.
//
// One new status can now come back from a keyed write: **425**, the sidecar's
// mapping of `bridge.busy` — a command under this key is still in flight on the
// shard. It is transient and retryable, and `shardAnnounce.classify` already
// treats it so by falling through to its retry case.
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('../core').logger('uo-link-client')
// The sidecar waits up to 10s on the shard before answering 504, so this sits
// just above it — every call answers rather than being abandoned mid-flight.
//
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
// fires first and classifies the step `retry` without asking the module, which
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
// relationship and its test asserts it, and both need the number to come from
// here rather than from a copy that can drift.
const TIMEOUT_MS = 12000
const CONFIG_TTL_MS = 5000
let cachedConfig = null
let cachedAt = 0
// Read (and briefly cache) the connection config incl. decrypted token.
async function resolveConfig() {
const now = Date.now()
if (cachedConfig && now - cachedAt < CONFIG_TTL_MS) return cachedConfig
cachedConfig = await uoLinkConfig.getWithToken()
cachedAt = now
return cachedConfig
}
// Drop the cache after a save so the next call picks up new URL/token immediately.
function invalidateConfig() {
cachedConfig = null
cachedAt = 0
}
// Core request. Returns { ok, data, status, error }. `ok` is true only on a 2xx
// with a parseable JSON body. Non-2xx responses still return their status + body
// so callers can distinguish 503 (shard restarting — transient) from 404.
async function call(path, { method = 'GET', body } = {}) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
// resolveConfig() decrypts the stored auth token, and decryption THROWS when the
// ciphertext can't be authenticated — SECRET_ENC_KEY was rotated, or a DB dump was
// restored into an environment keyed differently. It must stay INSIDE the try: out
// here it escaped `call()` entirely and 500'd every live-shard route (admin and
// player character/roster/vendor lookups, GET /admin/uo-link/config) instead of
// degrading to "shard unavailable". This module never throws — see the header.
let configResolved = false
try {
const config = await resolveConfig()
configResolved = true
if (!config || !config.baseUrl) {
return { ok: false, status: 0, error: 'uo-link is not configured' }
}
const headers = {
'Content-Type': 'application/json',
'X-UOLink-Version': String(config.protocol || 3),
}
if (config.token) headers.Authorization = `Bearer ${config.token}`
const res = await fetch(`${config.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
let data = null
try {
data = await res.json()
} catch {
// Non-JSON (or empty) body — leave data null; status still reported.
}
if (!res.ok) {
if (res.status === 401) log.warn('uo-link rejected auth token (401)', { path })
if (res.status === 409) log.error('uo-link protocol mismatch (409)', { path, body: data })
return { ok: false, status: res.status, data, error: `sidecar responded ${res.status}` }
}
return { ok: true, status: res.status, data }
} catch (err) {
// A failure before the config resolved is a misconfiguration, not a flaky
// sidecar: log it loudly (and distinctly) so "the shard looks offline" doesn't
// silently mean "the token can no longer be decrypted".
if (!configResolved) {
log.error('uo-link config unreadable — is SECRET_ENC_KEY the key the stored token was encrypted with?', {
path,
message: err.message,
})
return { ok: false, status: 0, error: 'uo-link config unreadable' }
}
log.warn('uo-link call failed', { path, message: err.message })
return { ok: false, status: 0, error: err.message }
} finally {
clearTimeout(timeout)
}
}
// ── Read queries ───────────────────────────────────────────────────────────
// Liveness (no auth required by the sidecar, but we send it anyway).
const health = () => call('/health')
const getCharBySerial = (serial) => call(`/char/serial/${encodeURIComponent(serial)}`)
const getCharBySlot = (account, slot) =>
call(`/char/${encodeURIComponent(account)}/${encodeURIComponent(slot)}`)
const getRoster = (account) => call(`/roster/${encodeURIComponent(account)}`)
const getVendors = (account) => call(`/vendors/${encodeURIComponent(account)}`)
// History / economy series — used for WS-reconnect backfill and public feeds.
function getHistory({ kind, limit = 100 } = {}) {
const params = new URLSearchParams()
if (kind) params.set('kind', kind)
if (limit) params.set('limit', String(limit))
const qs = params.toString()
const suffix = qs ? `?${qs}` : ''
return call(`/history${suffix}`)
}
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
// Live board / queue projections — snapshotted on WS (re)connect and served from
// our own store thereafter.
const getChamps = () => call('/champs')
const getPages = () => call('/pages')
// Protocol 2.0 board projections — same snapshot-on-connect pattern.
const getGuilds = () => call('/guilds')
const getGovernors = () => call('/governors')
const getHouses = () => call('/houses')
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
// Protocol 3.0: the shard's published ruleset. Object-shaped, not a board — the
// sidecar answers `{ ruleset: null }` until the shard has published one.
const getRuleset = () => call('/ruleset')
// Protocol 3.0: points/loyalty leaderboards. `/points` is board-shaped (an array
// under `boards`); the per-system read 404s for a system the shard never published.
const getPoints = () => call('/points')
const getPointsBoard = (system) => call(`/points/${encodeURIComponent(system)}`)
// Protocol 3.0: the player-vendor market index. The one PAGED sidecar read — a
// whole-world market does not fit in a response — so it answers with
// `{ vendors, total, limit, offset }` and the caller walks it (see uoLinkSocket).
const getMarket = ({ limit = 200, offset = 0 } = {}) =>
call(`/market?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`)
// ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) =>
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
// Account provisioning (Protocol 2.0). createAccount provisions a game account and
// auto-links it to the website user in one step; `ip` is the END USER's browser IP
// (read from the request), which the shard needs for its per-IP account cap — the
// sidecar only sees our server. The password is hashed on the shard and never
// appears in any reply/event/log. unlinkAccount severs a game account's tie from
// the site side. `actor` is the staff/website id, recorded in the shard audit.
const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
call('/accounts/create', {
method: 'POST',
body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip },
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
// in the in-game News window; re-posting the same id REPLACES it. `announce`
// (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
call('/news', {
method: 'POST',
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
})
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ─────────────────────────────────────────────────
// Every call carries `actor` — the website username of the staff member — set by
// the controller from the session, NEVER from the browser. The shard records it
// for attribution and echoes an admin.audit event back over the WS feed.
const adminKick = ({ actor, account, serial }) =>
call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
//
// Leases and the run-scoped participation ledger. Both are gated on the shard by
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
// operator consenting to staff moderation from a screen has not thereby consented
// to the website changing their world on a schedule at four in the morning. A
// shard with the plane off answers 403, and the actions turn that into a refusal
// an author can read rather than a retry.
// Every lease this shard offers, with what each is worth right now and what is
// holding it. One read serves both questions core asks — `read()` wants the
// current value, `inForce()` wants to know whether the shard still has a record
// of the hold — so a lease costs one round trip, not two.
const getLeases = () => call('/lease')
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
// computed here and honoured there is a deadline measured against two clocks, and
// a shard running ten minutes fast would restore a ten-minute lease the moment it
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
const applyLease = ({ key, value, holdMs, untilMs, runId, idempotencyKey }) =>
call('/lease', {
method: 'POST',
body: { key, value: String(value), holdMs, untilMs, runId, idempotencyKey },
})
// `expected` is what this run applied and `baseline` is what to put back, both out
// of core's ledger rather than the shard's memory — so a release still works after
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
// reverts every config lease by design) answers honestly instead of refusing.
const releaseLease = ({ key, expected, baseline, idempotencyKey }) =>
call('/lease/release', {
method: 'POST',
body: {
key,
expected: expected == null ? undefined : String(expected),
baseline: baseline == null ? undefined : String(baseline),
idempotencyKey,
},
})
// The participation ledger. The area is a map, a point and a radius rather than a
// region name, because protocol 6's own walk established that the most specific
// region containing an event is routinely anonymous.
const openParticipation = ({ runId, map, x, y, radius, holdMs, idempotencyKey }) =>
call('/participation', {
method: 'POST',
body: { runId: String(runId), map, x, y, radius, holdMs, idempotencyKey },
})
// A POST for a read, and the reason is the phase's headline: on a well-attended
// run the shard walks its members across Core ticks rather than in one inbound
// call, so a repeat arriving mid-walk is answered `bridge.busy` (425). A read that
// can legitimately be refused as a repeat in flight is not a GET.
const snapshotParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/snapshot`, {
method: 'POST',
body: { idempotencyKey },
})
const closeParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/close`, {
method: 'POST',
body: { idempotencyKey },
})
// ── The world verbs (protocol 7) ───────────────────────────────
//
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
// `target`/`holdMs` for a gate.
//
// The shard registers every serial it places against the run and persists that
// registry, which is what makes `despawnWorld` below safe to point at a list of
// serials: it can only delete what the run actually owns.
const spawnWorld = (body) => call('/world', { method: 'POST', body })
// What the run still owns. A GET, unlike the participation snapshot: it carries no
// idempotency key and the shard answers it in one pass. An unknown run answers with an
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
// fact once the registry is the only record, and they stay the same fact across a
// restart, because the registry is written by the same world save as the objects it
// describes.
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
// Give back what the run owns. No `serials` means everything, which is the call
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
// already absent (a player killed it — an ordinary success), and `refused` was never
// this run's to delete.
const despawnWorld = ({ runId, serials, idempotencyKey }) =>
call(`/world/${encodeURIComponent(runId)}/despawn`, {
method: 'POST',
body: { serials, idempotencyKey },
})
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
module.exports = {
TIMEOUT_MS,
invalidateConfig,
health,
getCharBySerial,
getCharBySlot,
getRoster,
getVendors,
getHistory,
getEconomy,
getChamps,
getPages,
getGuilds,
getGovernors,
getHouses,
getPresence,
getRuleset,
getPoints,
getPointsBoard,
getMarket,
confirmLink,
linkLookup,
createAccount,
unlinkAccount,
postTownCrier,
deleteTownCrier,
postNews,
deleteNews,
getLeases,
applyLease,
releaseLease,
openParticipation,
snapshotParticipation,
closeParticipation,
spawnWorld,
ownedWorld,
despawnWorld,
adminKick,
adminBan,
adminUnban,
adminBroadcast,
respondPage,
closePage,
}