Files
Module-uo/server/model/shardState/shardState.model.js
wtclaude 6a276a7ec3
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m46s
feat(shard): ingest protocol 5 — decay schedule, vendor fees, login result
The website half of the protocol-5 bump. Engagement Phase 10.

Schema — twelve columns and two indexes.

shard_houses gains next_stage, estimated_collapse, decay_period_sec and
dynamic_decay. estimated_collapse is nullable and stays null far more often than
not, deliberately: under dynamic decay ServUO draws each stage at random on entry,
so collapse is knowable only at IDOC. A null means "not knowable", never "not yet
read".

shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at.
owner_acct is the structural one — the table has carried owner_name since protocol
3, but a character name joins to nothing, and only the game account reaches
shard_account_links. Until now a vendor row named an owner the site could not
resolve to a person. dismissal_at + owner_acct are what let Phase 11's
uo.vendor.expiring find "vendors about to be dismissed" and turn each into a
person, without scanning every shop.

Ingest.

Both new field groups arrive NESTED and are flattened into columns on the way in,
then re-nested on the way out — the same trick shardMarket already uses for
`location`. That is not stylistic: the visibility projection matches literal JSON
keys, so the stored read model and the live wire frame have to spell a group
identically or one admin rule covers only one of the two paths. It also means a
field added inside a group later inherits the group's gate instead of defaulting to
visible; there is a test that adds an imaginary future fee field and asserts exactly
that.

Two write-back asymmetries, both load-bearing:

  * ownerName is written ONLY when the frame carries one. house.update also writes
    that column, from a different sweep, and a pre-v5 overlay's house.decay carries
    no ownerName at all — coalescing to null would let every decay transition erase
    a name the registry had already resolved.
  * The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A
    schedule is a claim about the future and goes stale on its own: roll a shard
    back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value
    is nothing. A dismissal date nobody is maintaining is worse than none.

dismissalAt is taken from the shard rather than recomputed. The shard resolved it
against ServUO's two vendor systems, whose charge, funds and pay interval all
differ; re-deriving it here would be a second implementation of PlayerVendor's own
rule.

Visibility — three classifications, each chosen rather than inherited.

  * house.decay's `schedule` defaults to `anonymous`. The countdown IS the public
    IDOC page's content and a house at IDOC is already announced in game. Listed
    anyway so a shard that considers a precise collapse time an unfair advantage can
    raise it — and one nested rule takes the whole schedule with it.
  * vendor.listing's `fees` defaults to `admin`, the only default in the market
    feature that does not reproduce prior behaviour, because there is no prior
    behaviour to reproduce. Shop name, owner and location are already visible to any
    player through the in-game Vendor Search gump, which is the argument for
    publishing them. Held gold, daily charge and dismissal date are visible to the
    OWNER only, on that vendor's own gump. Publishing them anonymously would be a
    new disclosure and a targeting aid — which shops are about to be abandoned, and
    how much coin is in each.
  * account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds
    an admin may widen, and there is no rung below admin that an IP plus an auth
    verdict belongs on. The omission is the decision, and a test says so by name.

owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no
REST read model's column list — they exist for Phase 11's server-side trigger and
reach no client at all.

The pin, and the protocol-4 bug seen from the other side.

Both declaration sites go to 5 (the model constant and schema.sql's CREATE default),
plus the one-shot migration, guarded `protocol < 5` so an install that missed an
earlier step is carried the whole way.

The schema test used to assert `DEFAULT 4` at each site. That is exactly how
protocol 4 shipped with the emitters moved and one site left behind: every site
agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the
model, so the assertion is "the declarations AGREE", and the one-shot migration
test is written once against the current version instead of being hand-copied per
bump.

470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO
and the release sidecar.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:20:15 -05:00

761 lines
26 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,
// 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,
}))
}
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,
}