// 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 // Small coercion helpers, kept out of the upsert builders below so those stay // flat (each inline `?? null` / ternary otherwise adds to cognitive complexity). const orNull = (v) => v ?? null const toDate = (v) => (v ? new Date(v) : null) // Owner is an actor object (or null for an abandoned house); flatten to columns. const ownerFields = (owner) => ({ owner_serial: orNull(owner?.serial), owner_acct: orNull(owner?.acct), owner_name: orNull(owner?.name), }) // 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(shapeOnline) } // 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, // Registry fields (Protocol 2.0 house.update); undefined on decay-only rows. ownerName: r.owner_name, coOwners: r.co_owners, friends: r.friends, price: r.price == null ? null : Number(r.price), decay: r.decay, inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), 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) } // ── House registry (Protocol 2.0 house.update / house.remove) ────────────── // Richer per-house snapshot than the decay-transition feed. Writes only the // registry columns (+ shared location/owner fields); is_idoc/stage stay owned by // the house.decay path, so the two feeds never clobber each other. owner is an // actor object (or null for an abandoned house). async function upsertHouseRegistry(data) { if (!data || !data.serial) return const fields = { name: orNull(data.name), ...ownerFields(data.owner || null), co_owners: orNull(data.coOwners), friends: orNull(data.friends), price: orNull(data.price), decay: orNull(data.decay), region: orNull(data.region), map: orNull(data.map), x: orNull(data.x), y: orNull(data.y), z: orNull(data.z), built_on: toDate(data.builtOn), last_refreshed: toDate(data.lastRefreshed), in_registry: 1, } await db.upsertHouse(data.serial, fields) } const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve()) async function listHouses() { const rows = await db.listRegistryHouses() 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: orNull(ev.category), type: orNull(ev.type), name: orNull(ev.name), status: orNull(ev.status), active: ev.active ? 1 : 0, map: orNull(ev.map), x: orNull(ev.x), y: orNull(ev.y), z: orNull(ev.z), 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: orNull(ev.type), sender_name: orNull(sender.name), sender_acct: orNull(sender.acct), web_id: orNull(sender.webId), message: orNull(ev.message), map: orNull(ev.map), x: orNull(ev.x), y: orNull(ev.y), z: orNull(ev.z), sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null, handled: ev.handled ? 1 : 0, handler: orNull(ev.handler), 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) } // ── Guild board (Protocol 2.0) ───────────────────────────────────────────── // Upsert a guild's roster snapshot (guild.update). The leader is an actor object // flattened into leader_* columns; the full event lives in `payload`. async function upsertGuild(ev) { if (!ev || ev.id == null) return const leader = ev.leader || {} await db.upsertGuild(ev.id, { name: ev.name ?? null, abbr: ev.abbr ?? null, members: ev.members ?? null, online: ev.online ?? null, alliance: ev.alliance ?? null, leader_serial: leader.serial ?? null, leader_name: leader.name ?? null, leader_acct: leader.acct ?? null, leader_web_id: leader.webId ?? null, payload: JSON.stringify(ev), t: Number.isFinite(ev.t) ? ev.t : null, }) } const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id)) const clearGuilds = () => db.clearGuilds() function shapeGuild(r) { const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload return payload || { kind: 'guild.update', id: r.id, name: r.name, abbr: r.abbr, members: r.members, online: r.online, alliance: r.alliance, leader: r.leader_serial ? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id } : null, t: r.t, } } async function listGuilds() { const rows = await db.listGuilds() return rows.map(shapeGuild) } // Replace the board with a fresh snapshot (sidecar GET /guilds on connect). async function replaceGuilds(guilds) { await db.clearGuilds() for (const ev of guilds || []) await upsertGuild(ev) } // The guild an actor leads (cross-link on the character sheet). Leadership only — // see the db note; membership for rank-and-file isn't in the feed, so we return // null rather than show a possibly-stale guess. async function findGuildForActor({ serial, acct }) { const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null) const g = rows[0] if (!g) return null return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' } } // Guilds led by any of a user's linked accounts (admin user-detail cross-link). async function listGuildsLedForAccounts(accounts) { const rows = await db.listGuildsLedByAccounts(accounts) return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name })) } // ── Town governors (Protocol 2.0) ────────────────────────────────────────── // Upsert a city's governance snapshot (city.update) AND capture term history. // Term capture runs first (it reads the CURRENT open term to decide whether the // governor changed) and is idempotent: a repeat/backfill of the same governor is a // no-op, so it's safe to call on the live feed and on reconnect snapshots alike. async function upsertGovernor(ev) { if (!ev || !ev.city) return await recordGovernorTransition(ev) const gov = ev.governor const elect = ev.governorElect await db.upsertGovernor(ev.city, { governor_serial: orNull(gov?.serial), governor_name: orNull(gov?.name), governor_acct: orNull(gov?.acct), governor_web_id: orNull(gov?.webId), elect_serial: orNull(elect?.serial), elect_name: orNull(elect?.name), elect_acct: orNull(elect?.acct), election_phase: orNull(ev.electionPhase), candidates: orNull(ev.candidates), auto_pick_at: toDate(ev.autoPickAt), payload: JSON.stringify(ev), t: Number.isFinite(ev.t) ? ev.t : null, }) } // Close the open term and open a new one when the governor CHANGES. Idempotent: // same governor as the open term ⇒ nothing happens (so backfill/duplicate // city.update events never spawn spurious terms). async function recordGovernorTransition(ev) { const gov = ev.governor || null const newSerial = gov ? gov.serial ?? null : null const t = Number.isFinite(ev.t) ? ev.t : Date.now() const open = await db.currentGovernorTerm(ev.city) const openSerial = open ? open.governor_serial : null if (open && openSerial === newSerial) return // unchanged — nothing to record if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated if (newSerial) { await db.openGovernorTerm({ city: ev.city, serial: newSerial, name: gov.name ?? null, acct: gov.acct ?? null, webId: gov.webId ?? null, startedAt: t, }) } } function shapeGovernor(r) { const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload return payload || { kind: 'city.update', city: r.city, governor: r.governor_serial ? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id } : null, governorElect: r.elect_serial ? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct } : null, electionPhase: r.election_phase, candidates: r.candidates, t: r.t, } } async function listGovernors() { const rows = await db.listGovernors() return rows.map(shapeGovernor) } // Cities the given game accounts currently govern (cross-link badge). async function listGovernorshipsForAccounts(accounts) { const rows = await db.listGovernorshipsByAccounts(accounts) return rows.map(shapeGovernor) } // Term history for a city (look-back), newest first. async function listGovernorHistory(city, limit = 100) { const n = Math.min(Math.max(Number(limit) || 100, 1), 500) const rows = await db.listGovernorTerms(city, n) return rows.map((r) => ({ city: r.city, governor: r.governor_serial ? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id } : null, startedAt: r.started_at == null ? null : Number(r.started_at), endedAt: r.ended_at == null ? null : Number(r.ended_at), votes: r.votes, })) } // Upsert governors without clearing (cities are fixed, no remove event); term // capture inside upsertGovernor stays idempotent across reconnect snapshots. async function replaceGovernors(cities) { for (const ev of cities || []) await upsertGovernor(ev) } // ── Online-population snapshot (Protocol 2.0 presence.online) ─────────────── async function setPresence(ev) { if (!ev) return await db.setPresence({ count: ev.count, byFacet: ev.byFacet || null, byRegion: ev.byRegion || null, t: ev.t, }) } async function latestPresence() { const r = await db.latestPresence() if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null } const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {}) return { count: Number(r.count) || 0, byFacet: parse(r.by_facet), byRegion: parse(r.by_region), t: r.t == null ? null : Number(r.t), } } // ── Shard ruleset (Protocol 3.0 world.ruleset) ───────────────────────────── // // The whole frame is stored in `payload` and served back whole. Nothing is // normalized out of it: it is a flat description of config read as one page, and // splitting it into columns would mean a schema change every time the shard grows // a new block. `rev` and `expansion` are hoisted only because they are cheap to // index/display, following shard_champs' payload-plus-hoisted-columns pattern. async function setRuleset(ev) { if (!ev) return await db.setRuleset({ rev: ev.rev ?? null, expansion: ev.expansion ?? null, payload: JSON.stringify(ev), t: ev.t, }) } // The stored ruleset, or null when the shard has never published one (an old // plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page // says "not published yet" rather than rendering an empty ruleset as if the shard // had no rules — so it is deliberately not smoothed into {}. async function getRuleset() { const r = await db.getRuleset() if (!r) return null const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload if (!payload) return null return { ...payload, updatedAt: r.updated_at } } 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, upsertHouseRegistry, removeHouse, listHouses, upsertChamp, removeChamp, clearChamps, listChamps, replaceChamps, upsertPage, removePage, clearPages, listPages, replacePages, upsertGuild, removeGuild, clearGuilds, listGuilds, replaceGuilds, findGuildForActor, listGuildsLedForAccounts, upsertGovernor, listGovernors, listGovernorshipsForAccounts, listGovernorHistory, replaceGovernors, setPresence, latestPresence, setRuleset, getRuleset, }