// 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() function shapeOnline(r) { return { 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, } } // Only players whose account is linked to a website user (opt-in visibility). async function listOnlineLinked() { const rows = await db.listOnlineLinked() return rows.map(shapeOnline) } 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) } function shapeHouse(r) { return { 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, } } async function listIdoc() { const rows = await db.listIdocHouses() return rows.map(shapeHouse) } // Houses owned by the given game accounts (admin: a user's linked accounts). async function listHousesForAccounts(accounts) { const rows = await db.listHousesByAccounts(accounts) return rows.map(shapeHouse) } // Online players on the given game accounts (admin: a user's linked accounts). async function listOnlineForAccounts(accounts) { const rows = await db.listOnlineByAccounts(accounts) return rows.map(shapeOnline) } // ── Champion spawns ──────────────────────────────────────────────────────── // Upsert a champ spawn's state (champ.update). The full event is stored in // `payload` for the category-specific fields; a few columns are hoisted out for // querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up). async function upsertChamp(ev) { if (!ev || !ev.serial) return await db.upsertChamp(ev.serial, { category: ev.category ?? null, type: ev.type ?? null, name: ev.name ?? null, status: ev.status ?? null, active: ev.active ? 1 : 0, map: ev.map ?? null, x: ev.x ?? null, y: ev.y ?? null, z: ev.z ?? null, boss_up: ev.bossUp ? 1 : 0, payload: JSON.stringify(ev), t: Number.isFinite(ev.t) ? ev.t : null, }) } const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve()) const clearChamps = () => db.clearChamps() // Return the stored champ.update payload (the shape the sidecar/UI expect), // falling back to the hoisted columns if an older row lacks a payload. function shapeChamp(r) { const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload return payload || { kind: 'champ.update', serial: r.serial, category: r.category, type: r.type, name: r.name, status: r.status, active: Boolean(r.active), map: r.map, x: r.x, y: r.y, z: r.z, bossUp: Boolean(r.boss_up), t: r.t, } } async function listChamps() { const rows = await db.listChamps() return rows.map(shapeChamp) } // Replace the whole board with a fresh snapshot (sidecar GET /champs on connect). async function replaceChamps(spawns) { await db.clearChamps() for (const ev of spawns || []) await upsertChamp(ev) } // ── Help-page (support) queue ────────────────────────────────────────────── // Upsert a page (page.new / page.updated). The `sender` actor object carries the // name/acct/webId; the rest are top-level fields. async function upsertPage(ev) { const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial)) if (!pageId) return const sender = ev.sender || {} await db.upsertPage(pageId, { type: ev.type ?? null, sender_name: sender.name ?? null, sender_acct: sender.acct ?? null, web_id: sender.webId ?? null, message: ev.message ?? null, map: ev.map ?? null, x: ev.x ?? null, y: ev.y ?? null, z: ev.z ?? null, sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null, handled: ev.handled ? 1 : 0, handler: ev.handler ?? null, payload: JSON.stringify(ev), }) } const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve()) const clearPages = () => db.clearPages() function shapePage(r) { const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload return { pageId: r.page_id, type: r.type, sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id }, message: r.message, map: r.map, x: r.x, y: r.y, z: r.z, sentMs: r.sent_ms == null ? null : Number(r.sent_ms), handled: Boolean(r.handled), handler: r.handler, updatedAt: r.updated_at, // Keep the raw payload available for any field not hoisted above. payload: payload || undefined, } } async function listPages() { const rows = await db.listPages() return rows.map(shapePage) } // Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect). async function replacePages(pages) { await db.clearPages() for (const ev of pages || []) await upsertPage(ev) } function safeJson(s) { try { return JSON.parse(s) } catch { return null } } module.exports = { upsertOnline, setOffline, clearOnline, onlineCount, listOnline, listOnlineLinked, listOnlineForAccounts, addEconomySample, listEconomy, latestEconomy, upsertHouse, listIdoc, listHousesForAccounts, upsertChamp, removeChamp, clearChamps, listChamps, replaceChamps, upsertPage, removePage, clearPages, listPages, replacePages, }