Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds page could say a guild had 155 members but never who they were, and findGuildForActor deliberately answered only for leaders because membership for rank-and-file was not in the feed at all. Protocol 4 puts it there. `shard_guild_members` holds one row per member per guild, keyed on (guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes one. A guild.remove now clears the membership too, so a disbanded guild does not leave orphaned rows behind. The chunking needs explaining. A roster over the shard's per-frame cap arrives as several frames carrying seq/more/total. The sidecar reassembles them for its own GET /guilds board, but the live WebSocket feed and the /history backfill both carry the individual frames — so this ingest sees them unreassembled. It copes without buffering, because a table expresses what the sidecar's single JSON column could not: the frame carrying seq 0 clears the guild first, and every frame then upserts its own rows. Upsert rather than insert because the /history backfill replays stored frames on every reconnect, and a redelivery has to be a no-op rather than a duplicate-key error. The cost is a sub-second window during a multi-frame update where the table holds part of a roster; buffering to close it would duplicate the sidecar's reassembly for a projection that is already only as fresh as a 60s sweep. On visibility: both kinds are mapped to the existing `guilds` feature. Without that mapping rule 2 fails an unmapped kind closed to admin-only, which would have quietly kept rosters off the public page forever. Mapping them is safe because a roster is the first frame carrying locked fields inside an ARRAY of actors rather than one nested actor, and the projection walker already recurses into arrays and matches acct/webId by suffix — so a member's account name is stripped below admin by exactly the rule that already strips guild.leader.acct. There is a test for that specifically, because the difference is a public page listing character names versus one publishing 150 account names. `acct`/`web_id` are still stored, since that is what lets a linked member be matched to a site user; they are just never projected below admin. guild.leave is appended to the event log, as the departure counterpart to guild.join and for the same reason — it is what a "so-and-so left" feed reads. guild.roster stays out: it is board state like guild.update, and it is the one fat frame on the wire, so logging it would put a full membership snapshot into shard_events on every membership change. The PUBLIC_KINDS guard test caught the addition, which is what it is for; its expected set now carries a v4 group alongside the v3 one. Refs: docs/website/TEAMS.md Part 12 Phase 1 Co-Authored-By: Claude <noreply@anthropic.com>
714 lines
23 KiB
JavaScript
714 lines
23 KiB
JavaScript
// 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 = 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,
|
|
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,
|
|
}))
|
|
}
|
|
|
|
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,
|
|
replaceGuilds,
|
|
findGuildForActor,
|
|
listGuildsLedForAccounts,
|
|
upsertGovernor,
|
|
listGovernors,
|
|
listGovernorshipsForAccounts,
|
|
listGovernorHistory,
|
|
replaceGovernors,
|
|
setPresence,
|
|
latestPresence,
|
|
setRuleset,
|
|
getRuleset,
|
|
upsertPointsBoard,
|
|
listPointsBoards,
|
|
getPointsBoard,
|
|
}
|