Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1)

The site now ingests the sidecar's live WebSocket feed and persists it to its
own MariaDB, and re-broadcasts curated events to browsers over SSE.

- schema: shard_events (append-only notable-kind log, sha1 dedupe_key +
  INSERT IGNORE for idempotent reconnect backfill), shard_online (current
  players, upsert/refresh/remove), shard_economy (gold-supply series),
  shard_houses (per-house decay stage + derived is_idoc).
- model/shardEvents + model/shardState: the .db.js/.model.js split; writes
  take camelCase event data, reads are shaped; online upsert uses COALESCE so
  a partial char.vitals refresh never blanks login fields.
- utils/shardIngest: single dispatcher routing each kind to state writes
  and/or the event log, then the broadcaster. High-frequency kinds
  (char.vitals, economy.supply) update state only. A changed server.hello
  bootId clears the stale online roster. Deps are injected for unit testing.
- utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies
  the ws.hello protocol, backfills via /history + /economy on every
  (re)connect (dedupe handles overlap), reconnects with capped backoff, and
  mirrors connection state into uo_link_config. Self-guards: only connects when
  the integration is enabled with a token.
- utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin
  (all) channels, keepalive pings, per-client cleanup.
- server.js: start the ingest socket on boot (no-op until configured) and stop
  it + close SSE streams on graceful shutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
2026-07-11 02:08:56 -05:00
parent ab647756f0
commit 9d9f5aac28
11 changed files with 923 additions and 2 deletions

View File

@@ -0,0 +1,187 @@
// ── Shard event ingest dispatcher ──────────────────────────────────────────
//
// The single entry point for every event that arrives on the uo-link WebSocket
// feed (and for backfilled /history events on reconnect). It routes by kind:
// • state-changing kinds update shard_online / shard_economy / shard_houses,
// • notable kinds are appended to the append-only shard_events log,
// • every kind is fanned out to the SSE broadcaster (which decides public vs
// admin visibility).
// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged
// to shard_events — they only update state — keeping the event log lean.
//
// Dependencies are injected (defaulting to the real models) so the routing can
// be unit-tested with mocked writes.
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
const shardStateModel = require('../model/shardState/shardState.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const broadcaster = require('./shardBroadcast')
const defaultLog = require('./logger')('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
// (char.vitals, economy.supply, mob.login/logout, account.login.attempt,
// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled
// specially — logged only on the transition INTO IDOC.
const LOGGED_KINDS = new Set([
'vendor.sale',
'player.death',
'player.murdered',
'mob.killed',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'audit.set',
'audit.command',
'cheat.fastwalk',
'link.request',
'server.hello',
'server.shutdown',
'server.crashed',
])
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
// can be detected and stale online state dropped. Module-level so it survives
// across events within a process; reset() is exposed for tests.
const state = { bootId: null }
function reset() {
state.bootId = null
}
// Should this event be written to the append-only log?
function shouldLog(event) {
if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC'
return LOGGED_KINDS.has(event.kind)
}
// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps
switch (event.kind) {
case 'server.hello': {
const incoming = event.bootId || null
if (incoming && state.bootId && incoming !== state.bootId) {
log.warn('shard restarted (bootId changed) — clearing online roster', {
from: state.bootId,
to: incoming,
})
await shardState.clearOnline()
}
if (incoming) state.bootId = incoming
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
return
}
case 'server.shutdown':
case 'server.crashed':
// Shard is going away — nobody is online anymore.
await shardState.clearOnline()
await uoLinkConfig.recordStatus({ pluginConnected: false })
return
case 'mob.login': {
const who = event.who || {}
await shardState.upsertOnline({
serial: who.serial,
name: who.name,
acct: who.acct,
webId: event.webId,
map: event.map,
x: event.x,
y: event.y,
z: event.z,
})
return
}
case 'mob.logout': {
const who = event.who || {}
if (who.serial) await shardState.setOffline(who.serial)
return
}
case 'char.vitals':
await shardState.upsertOnline({
serial: event.serial,
hits: event.hits,
hitsMax: event.hitsMax,
mana: event.mana,
manaMax: event.manaMax,
stam: event.stam,
stamMax: event.stamMax,
str: event.str,
dex: event.dex,
int: event.int,
map: event.map,
x: event.x,
y: event.y,
})
return
case 'economy.supply':
await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t })
return
case 'house.decay':
await shardState.upsertHouse({
serial: event.serial,
stage: event.to,
map: event.map,
x: event.x,
y: event.y,
z: event.z,
region: event.region,
name: event.name,
ownerSerial: event.ownerSerial,
ownerAcct: event.ownerAcct,
builtOn: event.builtOn,
lastRefreshed: event.lastRefreshed,
})
return
default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest().
}
}
// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill`
// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the
// live ticker). Never throws — a bad single event must not kill the feed.
async function ingest(event, deps = {}) {
const d = {
shardEvents: deps.shardEvents || shardEventsModel,
shardState: deps.shardState || shardStateModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
broadcast: deps.broadcast || broadcaster.broadcast,
log: deps.log || defaultLog,
}
if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
// ws.hello / pong are transport frames, not game events.
if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false }
const t = Number.isFinite(event.t) ? event.t : Date.now()
let stored = false
let logged = false
try {
await applyStateChange(event, d)
} catch (err) {
d.log.warn('state-change write failed', { kind: event.kind, message: err.message })
}
if (shouldLog(event)) {
logged = true
try {
stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event })
} catch (err) {
d.log.warn('event log write failed', { kind: event.kind, message: err.message })
}
}
if (!deps.fromBackfill) {
try {
d.broadcast(event)
} catch (err) {
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
}
}
return { logged, stored }
}
module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state }