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:
83
server/src/model/shardState/shardState.db.js
Normal file
83
server/src/model/shardState/shardState.db.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── Online players ─────────────────────────────────────────────────────────
|
||||
const ONLINE_COLS =
|
||||
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
|
||||
|
||||
// Upsert one online player. `fields` already prepared by the model (only the
|
||||
// columns it wants to write); serial is required and is the primary key.
|
||||
async function upsertOnline(serial, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['serial', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
// Never overwrite an existing column with NULL on refresh (a char.vitals frame
|
||||
// that omits acct/name shouldn't blank what mob.login set) — COALESCE keeps the
|
||||
// prior value when the incoming one is NULL.
|
||||
const updates = cols.map((c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_online (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[serial, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
|
||||
const clearOnline = () => query('DELETE FROM shard_online')
|
||||
|
||||
async function countOnline() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM shard_online')
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
const listOnline = () =>
|
||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||
|
||||
// ── Economy supply series ────────────────────────────────────────────────
|
||||
const insertEconomy = ({ accounts, gold, t }) =>
|
||||
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
||||
accounts ?? null,
|
||||
gold ?? null,
|
||||
t,
|
||||
])
|
||||
|
||||
const listEconomy = (limit) =>
|
||||
query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit])
|
||||
|
||||
async function latestEconomy() {
|
||||
const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1')
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Houses / IDOC ────────────────────────────────────────────────────────
|
||||
const HOUSE_COLS =
|
||||
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
|
||||
|
||||
async function upsertHouse(serial, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['serial', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_houses (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[serial, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const listIdocHouses = () =>
|
||||
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
clearOnline,
|
||||
countOnline,
|
||||
listOnline,
|
||||
insertEconomy,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
}
|
||||
141
server/src/model/shardState/shardState.model.js
Normal file
141
server/src/model/shardState/shardState.model.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// Live shard state derived from the WS feed: who is online, the gold-supply
|
||||
// series, and per-house decay stage. The ingest dispatcher calls the write
|
||||
// methods; the public read endpoints call the list/count methods. Writes take
|
||||
// camelCase semantic objects and map to the snake_case columns; only the keys
|
||||
// present are written (so a char.vitals refresh doesn't clobber login fields).
|
||||
|
||||
const db = require('./shardState.db')
|
||||
|
||||
const MAX_ECONOMY = 1000
|
||||
|
||||
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
||||
// partial refresh only touches the fields it carries.
|
||||
function onlineFields(data) {
|
||||
const map = {
|
||||
name: data.name,
|
||||
acct: data.acct,
|
||||
web_id: data.webId,
|
||||
map: data.map,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
z: data.z,
|
||||
hits: data.hits,
|
||||
hits_max: data.hitsMax,
|
||||
mana: data.mana,
|
||||
mana_max: data.manaMax,
|
||||
stam: data.stam,
|
||||
stam_max: data.stamMax,
|
||||
str: data.str,
|
||||
dex: data.dex,
|
||||
int: data.int,
|
||||
}
|
||||
const fields = {}
|
||||
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
|
||||
return fields
|
||||
}
|
||||
|
||||
// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
|
||||
async function upsertOnline(data) {
|
||||
if (!data || !data.serial) return
|
||||
await db.upsertOnline(data.serial, onlineFields(data))
|
||||
}
|
||||
|
||||
const setOffline = (serial) => db.removeOnline(serial)
|
||||
const clearOnline = () => db.clearOnline()
|
||||
const onlineCount = () => db.countOnline()
|
||||
|
||||
async function listOnline() {
|
||||
const rows = await db.listOnline()
|
||||
return rows.map((r) => ({
|
||||
serial: r.serial,
|
||||
name: r.name,
|
||||
acct: r.acct,
|
||||
webId: r.web_id,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
hits: r.hits,
|
||||
hitsMax: r.hits_max,
|
||||
mana: r.mana,
|
||||
manaMax: r.mana_max,
|
||||
stam: r.stam,
|
||||
stamMax: r.stam_max,
|
||||
str: r.str,
|
||||
dex: r.dex,
|
||||
int: r.int,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
// Append a gold-supply sample (economy.supply).
|
||||
async function addEconomySample({ accounts, gold, t }) {
|
||||
await db.insertEconomy({ accounts, gold, t })
|
||||
}
|
||||
|
||||
async function listEconomy(limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
|
||||
const rows = await db.listEconomy(n)
|
||||
// Return oldest → newest for charting.
|
||||
return rows
|
||||
.map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
async function latestEconomy() {
|
||||
const r = await db.latestEconomy()
|
||||
return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
|
||||
}
|
||||
|
||||
// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
|
||||
async function upsertHouse(data) {
|
||||
if (!data || !data.serial) return
|
||||
const fields = {
|
||||
stage: data.stage ?? null,
|
||||
map: data.map ?? null,
|
||||
x: data.x ?? null,
|
||||
y: data.y ?? null,
|
||||
z: data.z ?? null,
|
||||
region: data.region ?? null,
|
||||
name: data.name ?? null,
|
||||
owner_serial: data.ownerSerial ?? null,
|
||||
owner_acct: data.ownerAcct ?? null,
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map((r) => ({
|
||||
serial: r.serial,
|
||||
stage: r.stage,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
region: r.region,
|
||||
name: r.name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerAcct: r.owner_acct,
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
setOffline,
|
||||
clearOnline,
|
||||
onlineCount,
|
||||
listOnline,
|
||||
addEconomySample,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
}
|
||||
Reference in New Issue
Block a user