uo-link: staff-only public presence + admin character access #49
@@ -289,6 +289,85 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Append-only log of notable shard events ingested from the uo-link WebSocket
|
||||
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
|
||||
-- client writes here, and the public/admin read endpoints + live feeds read from
|
||||
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
|
||||
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||
-- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it
|
||||
-- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts.
|
||||
CREATE TABLE IF NOT EXISTS shard_events (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(48) NOT NULL,
|
||||
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
|
||||
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
|
||||
payload JSON NOT NULL, -- the full event object
|
||||
dedupe_key CHAR(40) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_events_kind_t (kind, t),
|
||||
INDEX idx_shard_events_t (t)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
|
||||
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
|
||||
-- server.hello.bootId). web_id is the linked website user id (present when the
|
||||
-- account is linked), so the roster can be correlated to site accounts.
|
||||
CREATE TABLE IF NOT EXISTS shard_online (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
|
||||
name VARCHAR(120) NULL,
|
||||
acct VARCHAR(120) NULL,
|
||||
web_id INT NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
hits INT NULL,
|
||||
hits_max INT NULL,
|
||||
mana INT NULL,
|
||||
mana_max INT NULL,
|
||||
stam INT NULL,
|
||||
stam_max INT NULL,
|
||||
str INT NULL,
|
||||
dex INT NULL,
|
||||
`int` INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_online_acct (acct)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
|
||||
-- append-only so the public status page can render a supply-over-time sparkline.
|
||||
CREATE TABLE IF NOT EXISTS shard_economy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
accounts INT NULL, -- number of accounts included in the total
|
||||
gold BIGINT NULL, -- total gold supply across all accounts
|
||||
t BIGINT NOT NULL, -- sample time, epoch ms
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_economy_t (t)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
|
||||
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
|
||||
-- indexed lookup rather than a scan.
|
||||
CREATE TABLE IF NOT EXISTS shard_houses (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(120) NULL,
|
||||
name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_acct VARCHAR(120) NULL,
|
||||
built_on DATETIME NULL,
|
||||
last_refreshed DATETIME NULL,
|
||||
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_houses_idoc (is_idoc)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||
-- writes them. They live in the same physical database as everything else
|
||||
|
||||
24
server/package-lock.json
generated
24
server/package-lock.json
generated
@@ -26,7 +26,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4",
|
||||
@@ -2386,6 +2387,27 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4",
|
||||
|
||||
31
server/src/model/shardEvents/shardEvents.db.js
Normal file
31
server/src/model/shardEvents/shardEvents.db.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
|
||||
// backfill overlap) is silently skipped rather than duplicated. Returns true if
|
||||
// a new row was actually inserted.
|
||||
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
||||
const res = await query(
|
||||
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// Recent events, newest first. Optional kind filter; limit is clamped by the model.
|
||||
async function list({ kind, limit }) {
|
||||
if (kind) {
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
|
||||
[kind, limit],
|
||||
)
|
||||
}
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events ORDER BY t DESC LIMIT ?`,
|
||||
[limit],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { insertIgnore, list }
|
||||
52
server/src/model/shardEvents/shardEvents.model.js
Normal file
52
server/src/model/shardEvents/shardEvents.model.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Append-only shard event log. The WS ingest dispatcher calls append() for the
|
||||
// notable kinds; the public/admin read endpoints call list(). The DB layer only
|
||||
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
|
||||
// WS-reconnect backfill.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./shardEvents.db')
|
||||
|
||||
const MAX_LIMIT = 1000
|
||||
const DEFAULT_LIMIT = 100
|
||||
|
||||
// Stable stringify — keys sorted — so the dedupe hash is independent of the
|
||||
// property order the sidecar happened to serialize with.
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||
const keys = Object.keys(value).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
||||
}
|
||||
|
||||
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
|
||||
// kind, same timestamp, same body) collapse to one row.
|
||||
function dedupeKey(kind, t, payload) {
|
||||
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
|
||||
}
|
||||
|
||||
// Append one event. Returns true if a new row was inserted (false = deduped).
|
||||
async function append({ kind, t, bootId, payload }) {
|
||||
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
|
||||
}
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit)
|
||||
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
|
||||
return Math.min(Math.floor(n), MAX_LIMIT)
|
||||
}
|
||||
|
||||
// Recent events, newest first. Each row's JSON payload is parsed back to an object.
|
||||
async function list({ kind, limit } = {}) {
|
||||
const rows = await db.list({ kind, limit: normalizeLimit(limit) })
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
t: row.t,
|
||||
bootId: row.boot_id || null,
|
||||
// mariadb returns JSON columns as strings on some versions; parse defensively.
|
||||
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { append, list, dedupeKey }
|
||||
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,
|
||||
}
|
||||
@@ -4,6 +4,8 @@ const http = require('http')
|
||||
const app = require('./app')
|
||||
const internalApp = require('./internalApp')
|
||||
const botScore = require('./middleware/botScore')
|
||||
const uoLinkSocket = require('./utils/uoLinkSocket')
|
||||
const shardBroadcast = require('./utils/shardBroadcast')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
@@ -77,6 +79,16 @@ async function start() {
|
||||
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
||||
})
|
||||
|
||||
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
||||
// connects when the admin has enabled the integration and saved a token, so
|
||||
// this is a no-op on shards that haven't configured the sidecar. Never let a
|
||||
// sidecar problem block server startup.
|
||||
try {
|
||||
await uoLinkSocket.start()
|
||||
} catch (err) {
|
||||
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||
}
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
|
||||
@@ -87,6 +99,8 @@ function setupShutdown(server, internalServer) {
|
||||
closing = true
|
||||
log.warn(`${signal} received — shutting down gracefully`)
|
||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
116
server/src/utils/shardBroadcast.js
Normal file
116
server/src/utils/shardBroadcast.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
|
||||
//
|
||||
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
||||
// never reach it, and the WS may be on another host). Instead the server ingests
|
||||
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
|
||||
// Events (plain HTTP — works through any reverse proxy).
|
||||
//
|
||||
// Two channels:
|
||||
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
||||
// no account-login attempts, no staff audit / cheat events.
|
||||
// • admin — everything, including the sensitive kinds above.
|
||||
//
|
||||
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||
// SSE route handlers call subscribe(req, res, channel).
|
||||
|
||||
const log = require('./logger')('shard-broadcast')
|
||||
|
||||
// Kinds safe to expose to unauthenticated browsers.
|
||||
const PUBLIC_KINDS = new Set([
|
||||
'vendor.sale',
|
||||
'player.death',
|
||||
'player.murdered',
|
||||
'mob.killed',
|
||||
'house.decay',
|
||||
'quest.complete',
|
||||
'skill.gain',
|
||||
'fame.change',
|
||||
'karma.change',
|
||||
'mob.login',
|
||||
'mob.logout',
|
||||
'economy.supply',
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
])
|
||||
|
||||
// Open response streams per channel.
|
||||
const clients = { public: new Set(), admin: new Set() }
|
||||
|
||||
const KEEPALIVE_MS = 25000
|
||||
|
||||
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
||||
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
||||
function subscribe(req, res, channel) {
|
||||
const bucket = clients[channel]
|
||||
if (!bucket) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
|
||||
})
|
||||
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||
res.write(': connected\n\n')
|
||||
|
||||
bucket.add(res)
|
||||
|
||||
const ping = setInterval(() => {
|
||||
try {
|
||||
res.write(': ping\n\n')
|
||||
} catch {
|
||||
/* write after close — cleanup below handles it */
|
||||
}
|
||||
}, KEEPALIVE_MS)
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(ping)
|
||||
bucket.delete(res)
|
||||
}
|
||||
req.on('close', cleanup)
|
||||
res.on('error', cleanup)
|
||||
}
|
||||
|
||||
function writeTo(bucket, payload) {
|
||||
for (const res of bucket) {
|
||||
try {
|
||||
res.write(payload)
|
||||
} catch (err) {
|
||||
log.warn('sse write failed; dropping client', { message: err.message })
|
||||
bucket.delete(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan an ingested event out to the admin channel (always) and the public
|
||||
// channel (safe kinds only). A no-op when nobody is subscribed.
|
||||
function broadcast(event) {
|
||||
if (!event || !event.kind) return
|
||||
const frame = `data: ${JSON.stringify(event)}\n\n`
|
||||
if (clients.admin.size) writeTo(clients.admin, frame)
|
||||
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
||||
}
|
||||
|
||||
// Close every open stream (graceful shutdown).
|
||||
function closeAll() {
|
||||
for (const channel of Object.values(clients)) {
|
||||
for (const res of channel) {
|
||||
try {
|
||||
res.end()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
channel.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function stats() {
|
||||
return { publicClients: clients.public.size, adminClients: clients.admin.size }
|
||||
}
|
||||
|
||||
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
|
||||
187
server/src/utils/shardIngest.js
Normal file
187
server/src/utils/shardIngest.js
Normal 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 }
|
||||
195
server/src/utils/uoLinkSocket.js
Normal file
195
server/src/utils/uoLinkSocket.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// ── 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 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const hist = await uoLinkClient.getHistory({ limit: BACKFILL_LIMIT })
|
||||
if (hist.ok && hist.data && Array.isArray(hist.data.events)) {
|
||||
// History is newest-first; replay oldest-first so latest-wins state (e.g.
|
||||
// house.decay stage) settles correctly.
|
||||
const events = [...hist.data.events].reverse()
|
||||
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
log.info('backfilled events from /history', { count: events.length })
|
||||
}
|
||||
const eco = await uoLinkClient.getEconomy(200)
|
||||
if (eco.ok && eco.data && Array.isArray(eco.data.series)) {
|
||||
const series = [...eco.data.series].reverse()
|
||||
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
}
|
||||
} 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', async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(raw.toString())
|
||||
} catch {
|
||||
log.warn('dropping non-JSON WS frame')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.kind === 'ws.hello') {
|
||||
helloSeen = true
|
||||
if (event.protocol && event.protocol !== state.protocol) {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
||||
|
||||
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
||||
await shardIngest.ingest(event)
|
||||
})
|
||||
|
||||
ws.on('close', async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
log.warn('uo-link WS error', { message: err.message })
|
||||
// 'close' fires after 'error'; reconnect is scheduled there.
|
||||
})
|
||||
}
|
||||
|
||||
// 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 }
|
||||
Reference in New Issue
Block a user