Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
239 lines
8.8 KiB
JavaScript
239 lines
8.8 KiB
JavaScript
// ── uo-link WebSocket ingest client ────────────────────────────────────────
|
|
//
|
|
// Long-lived client that connects to the sidecar's push-only WS feed and pumps
|
|
// every frame through the ingest dispatcher. This is the server's first
|
|
// outbound WebSocket. Lifecycle:
|
|
// • start() — connect if the config is enabled and has a token; verify the
|
|
// ws.hello protocol; backfill missed events via /history on every
|
|
// (re)connect (INSERT IGNORE dedupes the overlap); reconnect with
|
|
// capped backoff.
|
|
// • stop() — close the socket and stop reconnecting (graceful shutdown).
|
|
// Connection state is mirrored into uo_link_config (plugin_connected / status /
|
|
// last_event_at) so the admin panel and public status endpoint have live data.
|
|
|
|
const WebSocket = require('ws')
|
|
|
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
|
const uoLinkClient = require('./uoLinkClient')
|
|
const shardIngest = require('./shardIngest')
|
|
const shardState = require('../model/shardState/shardState.model')
|
|
const newsGump = require('./newsGump')
|
|
const log = require('./logger')('uo-link-socket')
|
|
|
|
const BACKOFF_MIN_MS = 1000
|
|
const BACKOFF_MAX_MS = 30000
|
|
const BACKFILL_LIMIT = 500
|
|
|
|
let ws = null
|
|
let reconnectTimer = null
|
|
let backoff = BACKOFF_MIN_MS
|
|
let running = false // set by start()/stop(); guards auto-reconnect
|
|
let helloSeen = false
|
|
|
|
const state = {
|
|
connected: false,
|
|
lastEventAt: null,
|
|
lastConnectedAt: null,
|
|
reconnects: 0,
|
|
protocol: null,
|
|
}
|
|
|
|
function buildUrl(wsUrl, token) {
|
|
const sep = wsUrl.includes('?') ? '&' : '?'
|
|
return token ? `${wsUrl}${sep}token=${encodeURIComponent(token)}` : wsUrl
|
|
}
|
|
|
|
// One guarded board snapshot: fetch, verify `data[key]` is an array, hand it to
|
|
// `apply`, and (when given) log `label` with the row count. Isolated so a
|
|
// failed/absent board never aborts the rest of backfill — and so backfill()
|
|
// stays a flat sequence rather than nine repetitions of the same guard.
|
|
async function snapshot(fetchFn, key, apply, label) {
|
|
const res = await fetchFn()
|
|
if (!res.ok || !res.data || !Array.isArray(res.data[key])) return
|
|
await apply(res.data[key])
|
|
if (label) log.info(label, { count: res.data[key].length })
|
|
}
|
|
|
|
// Replay events through the dispatcher oldest-first (history/economy arrive
|
|
// newest-first) so latest-wins state settles correctly.
|
|
async function ingestReversed(events) {
|
|
for (const ev of [...events].reverse()) await shardIngest.ingest(ev, { fromBackfill: true })
|
|
}
|
|
async function ingestEach(events) {
|
|
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
|
}
|
|
|
|
// Pull recent events from the sidecar's own store and replay them through the
|
|
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
|
// make this idempotent, so overlap with what we already stored is harmless.
|
|
async function backfill() {
|
|
try {
|
|
await snapshot(() => uoLinkClient.getHistory({ limit: BACKFILL_LIMIT }), 'events', ingestReversed, 'backfilled events from /history')
|
|
await snapshot(() => uoLinkClient.getEconomy(200), 'series', ingestReversed)
|
|
|
|
// Champ board + help-page queue have no replay stream — snapshot the
|
|
// authoritative current state directly (the sidecar guide's advice for both),
|
|
// reconciling our tables to it so a stale row from before a disconnect can't
|
|
// linger. Live champ.*/page.* deltas keep them fresh thereafter.
|
|
await snapshot(() => uoLinkClient.getChamps(), 'spawns', (s) => shardState.replaceChamps(s), 'snapshotted champ board from /champs')
|
|
await snapshot(() => uoLinkClient.getPages(), 'pages', (p) => shardState.replacePages(p), 'snapshotted help-page queue from /pages')
|
|
|
|
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
|
// Same as champs/pages: snapshot the authoritative current state and
|
|
// reconcile our tables to it. Each call is independently guarded so a
|
|
// failed/absent board (e.g. no City Loyalty → empty /governors) never wipes
|
|
// another. Governors are NOT cleared before upsert (cities are fixed and the
|
|
// term-capture is idempotent, so a reconnect can't spawn spurious terms).
|
|
await snapshot(() => uoLinkClient.getGuilds(), 'guilds', (g) => shardState.replaceGuilds(g), 'snapshotted guild board from /guilds')
|
|
await snapshot(() => uoLinkClient.getGovernors(), 'cities', (c) => shardState.replaceGovernors(c), 'snapshotted governor board from /governors')
|
|
await snapshot(() => uoLinkClient.getHouses(), 'houses', ingestEach, 'snapshotted house registry from /houses')
|
|
|
|
const presence = await uoLinkClient.getPresence()
|
|
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
|
await shardState.setPresence(presence.data)
|
|
log.info('snapshotted online population from /online', { count: presence.data.count })
|
|
}
|
|
|
|
// Re-assert our published news into the in-game Town Cryer News gump. The
|
|
// website is the source of truth; this reconciles the gump on every
|
|
// (re)connect (and recovers any article whose original live push failed).
|
|
// Silent (announce:false) so a reconnect never re-proclaims old news.
|
|
await newsGump.reassertAll()
|
|
} catch (err) {
|
|
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (!running) return
|
|
clearTimeout(reconnectTimer)
|
|
reconnectTimer = setTimeout(connect, backoff)
|
|
log.info(`reconnecting in ${backoff}ms`)
|
|
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
|
|
}
|
|
|
|
async function connect() {
|
|
if (!running) return
|
|
let config
|
|
try {
|
|
config = await uoLinkConfig.getWithToken()
|
|
} catch (err) {
|
|
log.error('could not read uo-link config', err)
|
|
scheduleReconnect()
|
|
return
|
|
}
|
|
if (!config || !config.enabled || !config.wsUrl || !config.token) {
|
|
log.info('uo-link WS not started (disabled or missing url/token)')
|
|
running = false
|
|
return
|
|
}
|
|
|
|
state.protocol = config.protocol || 1
|
|
helloSeen = false
|
|
const url = buildUrl(config.wsUrl, config.token)
|
|
|
|
try {
|
|
ws = new WebSocket(url)
|
|
} catch (err) {
|
|
log.error('failed to open WS', err)
|
|
scheduleReconnect()
|
|
return
|
|
}
|
|
|
|
ws.on('open', handleOpen)
|
|
ws.on('message', handleMessage)
|
|
ws.on('close', handleClose)
|
|
ws.on('error', (err) => {
|
|
log.warn('uo-link WS error', { message: err.message })
|
|
// 'close' fires after 'error'; reconnect is scheduled there.
|
|
})
|
|
}
|
|
|
|
// WS lifecycle handlers, split out of connect() so it stays a flat setup path.
|
|
async function handleOpen() {
|
|
log.info('uo-link WS connected')
|
|
state.connected = true
|
|
state.lastConnectedAt = Date.now()
|
|
backoff = BACKOFF_MIN_MS
|
|
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
|
await backfill()
|
|
}
|
|
|
|
// A ws.hello frame: mark it seen and, on a protocol mismatch, record the error
|
|
// and close (we won't run against an incompatible sidecar).
|
|
async function handleHello(event) {
|
|
helloSeen = true
|
|
if (!event.protocol || event.protocol === state.protocol) return
|
|
log.error('uo-link protocol mismatch on ws.hello — closing', { expected: state.protocol, got: event.protocol })
|
|
await uoLinkConfig
|
|
.recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` })
|
|
.catch(() => {})
|
|
running = false
|
|
try {
|
|
ws.close()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async function handleMessage(raw) {
|
|
let event
|
|
try {
|
|
event = JSON.parse(raw.toString())
|
|
} catch {
|
|
log.warn('dropping non-JSON WS frame')
|
|
return
|
|
}
|
|
|
|
if (event.kind === 'ws.hello') return handleHello(event)
|
|
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
|
|
|
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
|
await shardIngest.ingest(event)
|
|
}
|
|
|
|
async function handleClose() {
|
|
state.connected = false
|
|
if (running) state.reconnects += 1
|
|
log.warn('uo-link WS closed')
|
|
await uoLinkConfig
|
|
.recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false })
|
|
.catch(() => {})
|
|
ws = null
|
|
scheduleReconnect()
|
|
}
|
|
|
|
// Begin (or restart) the WS client. Idempotent — a running client is stopped
|
|
// first so a config save can re-point it at a new URL/token.
|
|
async function start() {
|
|
stop()
|
|
running = true
|
|
backoff = BACKOFF_MIN_MS
|
|
await connect()
|
|
}
|
|
|
|
// Stop the client and cancel any pending reconnect. Called on shutdown and
|
|
// before a restart.
|
|
function stop() {
|
|
running = false
|
|
clearTimeout(reconnectTimer)
|
|
reconnectTimer = null
|
|
if (ws) {
|
|
try {
|
|
ws.removeAllListeners()
|
|
ws.close()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
ws = null
|
|
}
|
|
state.connected = false
|
|
}
|
|
|
|
// Ingestion stats for the admin panel.
|
|
function getState() {
|
|
return { ...state, running }
|
|
}
|
|
|
|
module.exports = { start, stop, getState }
|