Files
Module-uo/server/model/shardState/shardState.model.js
wtclaude 99d1ca25a7
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s
feat(teams): ingest guild rank, and report every leader rather than one
The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).

`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.

The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.

## NULL rank is a real state, and it is load-bearing

The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:

  - the ingest stores NULL rather than defaulting to 0, which would be a
    demotion this code invented;
  - `leader` requires an integer rank >= 4, so absence is never leadership;
  - the leaders query compares on `rank`, and NULL is excluded by the comparison.

Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.

## Rank labels

Three sources, in order: a custom rank's literal string, then the operator's
cliloc table, then the five standard names. The last exists because the cliloc
table is populated only if someone ran the client-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.

`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.

The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.

## Verification

The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.

  stored:   0x1F5 rank=4  0x1F6 rank=3  0x1F7 rank=2  0x1F8 rank=1
            0x1F9 rank=NULL (the GameMaster)  0x2E0 rank=4
  provider: leaders = [0x1F5, 0x2E0]   <- two, which the board alone cannot express
            labels  = Leader / Warlord / Emissary / Member, with no cliloc table
            0x1F9   = not a leader, no label

9/9 checks. Suite 413 -> 421 tests, all passing.

Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:42:32 -05:00

722 lines
24 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,
// 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,
}))
}
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,
}