// 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, // Protocol 5. `ownerName` is written back only when the frame carries one, and // that asymmetry is deliberate: house.update also writes this column, from a // different sweep, and a pre-v5 overlay's house.decay frame has no ownerName at // all. Coalescing to null here would let every decay transition ERASE a name the // registry had already resolved. ...(data.ownerName ? { owner_name: String(data.ownerName).slice(0, 120) } : {}), ...decayScheduleFields(data.schedule), } await db.upsertHouse(data.serial, fields) } // Protocol 5's `schedule` object, flattened into its columns. // // Unlike ownerName above, these are written back UNCONDITIONALLY, including as nulls. // A schedule is a claim about the future and it goes stale on its own: if a shard is // rolled back to a pre-v5 overlay, or a house leaves IDOC so its collapse time stops // being knowable, the right stored value is "nothing" rather than the last thing we // were told. A dated promise nobody is maintaining is worse than no promise. function decayScheduleFields(schedule) { const s = schedule && typeof schedule === 'object' ? schedule : {} const when = (v) => { if (!v) return null const d = new Date(v) return Number.isNaN(d.getTime()) ? null : d } return { next_stage: when(s.nextStage), estimated_collapse: when(s.estimatedCollapse), decay_period_sec: Number.isFinite(s.decayPeriodSec) ? Math.trunc(s.decayPeriodSec) : null, dynamic_decay: typeof s.dynamicDecay === 'boolean' ? (s.dynamicDecay ? 1 : 0) : null, } } 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, // Protocol 5. Re-nested on read for the reason shardMarket re-nests `location`: // the visibility projection matches literal JSON keys, so the stored read model // and the live wire frame have to spell this the same way or the one admin rule // covers only one of the two paths. schedule: { dynamicDecay: r.dynamic_decay == null ? null : Boolean(r.dynamic_decay), nextStage: r.next_stage, decayPeriodSec: r.decay_period_sec, estimatedCollapse: r.estimated_collapse, }, 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 = async (id) => { if (id == null) return await db.removeGuild(id) await db.clearGuildMembers(id) } const clearGuilds = async () => { await db.clearGuilds() await db.clearAllGuildMembers() } // ── Guild membership (Protocol 4) ────────────────────────────────────────── // Apply one guild.roster frame. // // A roster larger than the shard's per-frame cap arrives as several frames // carrying seq/more/total. The sidecar reassembles them for its OWN board, but the // live WebSocket feed and the /history backfill both carry the individual frames, // so this ingest sees them unreassembled and has to cope. // // It copes without buffering, because a table can express what a single JSON column // could not: the frame carrying seq 0 clears the guild first and every frame then // upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame // — the /history backfill replays stored frames on every reconnect — is idempotent // rather than a duplicate-key error. // // The cost is a brief window during a multi-frame update where the table holds part // of a roster. That is acceptable for a projection that is already only as fresh as // a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close // it would duplicate the sidecar's reassembly for a sub-second inconsistency. async function upsertGuildRoster(ev) { if (!ev || ev.id == null) return const seq = Number.isFinite(ev.seq) ? ev.seq : 0 const members = Array.isArray(ev.members) ? ev.members : [] // seq 0 begins a roster and supersedes whatever was held for this guild. if (seq === 0) await db.clearGuildMembers(ev.id) const rows = members .filter((m) => m && m.serial) .map((m) => ({ guild_id: ev.id, serial: m.serial, name: m.name ?? null, acct: m.acct ?? null, web_id: Number.isFinite(m.webId) ? m.webId : null, is_player: m.player ? 1 : 0, // Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the // shard withholds the rank for a staff account, because ServUO's GuildRank // getter reports Leader for anyone at GameMaster or above whatever their // actual rank. Defaulting a missing rank to 0 here would turn "we were not // told" into "rank 0", which is a demotion invented by this line. rank: Number.isInteger(m.rank) ? m.rank : null, rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null, rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null, t: Number.isFinite(ev.t) ? ev.t : null, })) await db.upsertGuildMembers(rows) } // A single departure (guild.leave). Advisory: the shard re-emits the full roster // whenever the member set changes, so the table would converge on the next frame // even if this were dropped. Applying it makes the change visible immediately // instead of at the end of the sweep that produced it. async function removeGuildMember(ev) { if (!ev || ev.id == null || !ev.who) return await db.removeGuildMember(ev.id, ev.who) } // The membership roster for one guild, in the wire shape the projection expects // (an array of actor objects), so shardVisibility strips acct/webId by the same // rule it applies to guild.leader. async function listGuildMembers(guildId) { const rows = await db.listGuildMembers(guildId) return rows.map((r) => ({ serial: r.serial, name: r.name, ...(r.acct == null ? {} : { acct: r.acct }), ...(r.web_id == null ? {} : { webId: r.web_id }), player: !!r.is_player, })) } // **Just the accounts, for the engagement audiences** (Phase 11). Deliberately // NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected // through `shardVisibility`, which strips `acct` for anyone below admin, so // building an audience out of it would either leak the projection's job into // this one or silently resolve to nobody depending on who asked. These two go to // the database for exactly the column they need and pass nothing else on. const listGuildMemberAccounts = async (guildId) => (await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean) const listGovernorAccounts = async () => (await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean) 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 } } // ── Points/loyalty boards (Protocol 3.0 points.board) ────────────────────── // // The whole frame is stored in `payload`; the columns beside it are hoisted for // listing and ordering only. The top-N list deliberately stays inside the payload // (see schema.sql) — it is a fixed-size list read whole, like the governor board's // candidates. async function upsertPointsBoard(ev) { if (!ev || !ev.system) return await db.upsertPointsBoard({ system: String(ev.system).slice(0, 48), name: ev.nameString ?? null, nameCliloc: ev.nameNumber, maxPoints: ev.maxPoints, players: ev.players, showOnGump: ev.showOnGump !== false, payload: JSON.stringify(ev), t: ev.t, }) } // A stored frame plus the freshness stamp. `top` is normalized to an array so a // caller never has to guard it — a board with nobody on it is a real state (a // system nobody has scored in yet), distinct from a system that was never // published at all, which is absent from the table entirely. function shapePointsBoard(r) { const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {} return { ...payload, system: r.system, top: Array.isArray(payload.top) ? payload.top : [], updatedAt: r.updated_at, } } async function listPointsBoards() { const rows = await db.listPointsBoards() return rows.map(shapePointsBoard) } async function getPointsBoard(system) { const r = await db.getPointsBoard(system) return r ? shapePointsBoard(r) : null } 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, upsertGuildRoster, removeGuildMember, listGuildMembers, listGuildMemberAccounts, listGovernorAccounts, replaceGuilds, findGuildForActor, listGuildsLedForAccounts, upsertGovernor, listGovernors, listGovernorshipsForAccounts, listGovernorHistory, replaceGovernors, setPresence, latestPresence, setRuleset, getRuleset, upsertPointsBoard, listPointsBoards, getPointsBoard, }