Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.
Server
- shard_ruleset: a singleton table (id = 1) holding the whole frame in
`payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
the frame 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 block.
- shardIngest routes world.ruleset to setRuleset and deliberately does NOT
log it — the shard re-emits the whole ruleset on every sidecar connect, so
logging would append a duplicate row per reconnect, and server.hello already
marks each of those.
- uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
which asserts an array; this covers the order where the sidecar was already
up and holding the ruleset when we reconnected.
- GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
means the shard has never published one — a real answer, distinct from a
published ruleset, and the page says so.
Client
- routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
complete ruleset, not a delta, so the newest one wins outright). Caps are
rendered from tenths — 7000 is 700.0, and showing the raw number would
mislead. A systems key this build doesn't know still renders, humanised, so
a newer plugin can't go invisible against an older client.
- Nav entry gated on the `ruleset` feature, so it hides rather than 403s.
Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.
497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
592 lines
19 KiB
JavaScript
592 lines
19 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 = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
|
const clearGuilds = () => db.clearGuilds()
|
|
|
|
function shapeGuild(r) {
|
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
|
return payload || {
|
|
kind: 'guild.update',
|
|
id: r.id,
|
|
name: r.name,
|
|
abbr: r.abbr,
|
|
members: r.members,
|
|
online: r.online,
|
|
alliance: r.alliance,
|
|
leader: r.leader_serial
|
|
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
|
|
: null,
|
|
t: r.t,
|
|
}
|
|
}
|
|
|
|
async function listGuilds() {
|
|
const rows = await db.listGuilds()
|
|
return rows.map(shapeGuild)
|
|
}
|
|
|
|
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
|
|
async function replaceGuilds(guilds) {
|
|
await db.clearGuilds()
|
|
for (const ev of guilds || []) await upsertGuild(ev)
|
|
}
|
|
|
|
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
|
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
|
// null rather than show a possibly-stale guess.
|
|
async function findGuildForActor({ serial, acct }) {
|
|
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
|
const g = rows[0]
|
|
if (!g) return null
|
|
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
|
}
|
|
|
|
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
|
async function listGuildsLedForAccounts(accounts) {
|
|
const rows = await db.listGuildsLedByAccounts(accounts)
|
|
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
|
}
|
|
|
|
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
|
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
|
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
|
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
|
|
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
|
|
async function upsertGovernor(ev) {
|
|
if (!ev || !ev.city) return
|
|
await recordGovernorTransition(ev)
|
|
const gov = ev.governor
|
|
const elect = ev.governorElect
|
|
await db.upsertGovernor(ev.city, {
|
|
governor_serial: orNull(gov?.serial),
|
|
governor_name: orNull(gov?.name),
|
|
governor_acct: orNull(gov?.acct),
|
|
governor_web_id: orNull(gov?.webId),
|
|
elect_serial: orNull(elect?.serial),
|
|
elect_name: orNull(elect?.name),
|
|
elect_acct: orNull(elect?.acct),
|
|
election_phase: orNull(ev.electionPhase),
|
|
candidates: orNull(ev.candidates),
|
|
auto_pick_at: toDate(ev.autoPickAt),
|
|
payload: JSON.stringify(ev),
|
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
|
})
|
|
}
|
|
|
|
// Close the open term and open a new one when the governor CHANGES. Idempotent:
|
|
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
|
|
// city.update events never spawn spurious terms).
|
|
async function recordGovernorTransition(ev) {
|
|
const gov = ev.governor || null
|
|
const newSerial = gov ? gov.serial ?? null : null
|
|
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
|
|
const open = await db.currentGovernorTerm(ev.city)
|
|
const openSerial = open ? open.governor_serial : null
|
|
if (open && openSerial === newSerial) return // unchanged — nothing to record
|
|
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
|
|
if (newSerial) {
|
|
await db.openGovernorTerm({
|
|
city: ev.city,
|
|
serial: newSerial,
|
|
name: gov.name ?? null,
|
|
acct: gov.acct ?? null,
|
|
webId: gov.webId ?? null,
|
|
startedAt: t,
|
|
})
|
|
}
|
|
}
|
|
|
|
function shapeGovernor(r) {
|
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
|
return payload || {
|
|
kind: 'city.update',
|
|
city: r.city,
|
|
governor: r.governor_serial
|
|
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
|
: null,
|
|
governorElect: r.elect_serial
|
|
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
|
|
: null,
|
|
electionPhase: r.election_phase,
|
|
candidates: r.candidates,
|
|
t: r.t,
|
|
}
|
|
}
|
|
|
|
async function listGovernors() {
|
|
const rows = await db.listGovernors()
|
|
return rows.map(shapeGovernor)
|
|
}
|
|
|
|
// Cities the given game accounts currently govern (cross-link badge).
|
|
async function listGovernorshipsForAccounts(accounts) {
|
|
const rows = await db.listGovernorshipsByAccounts(accounts)
|
|
return rows.map(shapeGovernor)
|
|
}
|
|
|
|
// Term history for a city (look-back), newest first.
|
|
async function listGovernorHistory(city, limit = 100) {
|
|
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
|
const rows = await db.listGovernorTerms(city, n)
|
|
return rows.map((r) => ({
|
|
city: r.city,
|
|
governor: r.governor_serial
|
|
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
|
: null,
|
|
startedAt: r.started_at == null ? null : Number(r.started_at),
|
|
endedAt: r.ended_at == null ? null : Number(r.ended_at),
|
|
votes: r.votes,
|
|
}))
|
|
}
|
|
|
|
// Upsert governors without clearing (cities are fixed, no remove event); term
|
|
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
|
|
async function replaceGovernors(cities) {
|
|
for (const ev of cities || []) await upsertGovernor(ev)
|
|
}
|
|
|
|
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
|
async function setPresence(ev) {
|
|
if (!ev) return
|
|
await db.setPresence({
|
|
count: ev.count,
|
|
byFacet: ev.byFacet || null,
|
|
byRegion: ev.byRegion || null,
|
|
t: ev.t,
|
|
})
|
|
}
|
|
|
|
async function latestPresence() {
|
|
const r = await db.latestPresence()
|
|
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
|
|
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
|
|
return {
|
|
count: Number(r.count) || 0,
|
|
byFacet: parse(r.by_facet),
|
|
byRegion: parse(r.by_region),
|
|
t: r.t == null ? null : Number(r.t),
|
|
}
|
|
}
|
|
|
|
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
|
//
|
|
// The whole frame is stored in `payload` and served back whole. Nothing is
|
|
// normalized out of it: it is a flat description of config read as one page, and
|
|
// splitting it into columns would mean a schema change every time the shard grows
|
|
// a new block. `rev` and `expansion` are hoisted only because they are cheap to
|
|
// index/display, following shard_champs' payload-plus-hoisted-columns pattern.
|
|
async function setRuleset(ev) {
|
|
if (!ev) return
|
|
await db.setRuleset({
|
|
rev: ev.rev ?? null,
|
|
expansion: ev.expansion ?? null,
|
|
payload: JSON.stringify(ev),
|
|
t: ev.t,
|
|
})
|
|
}
|
|
|
|
// The stored ruleset, or null when the shard has never published one (an old
|
|
// plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page
|
|
// says "not published yet" rather than rendering an empty ruleset as if the shard
|
|
// had no rules — so it is deliberately not smoothed into {}.
|
|
async function getRuleset() {
|
|
const r = await db.getRuleset()
|
|
if (!r) return null
|
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
|
if (!payload) return null
|
|
return { ...payload, updatedAt: r.updated_at }
|
|
}
|
|
|
|
function safeJson(s) {
|
|
try {
|
|
return JSON.parse(s)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
upsertOnline,
|
|
setOffline,
|
|
clearOnline,
|
|
onlineCount,
|
|
listOnline,
|
|
listOnlineLinked,
|
|
listOnlineForAccounts,
|
|
addEconomySample,
|
|
listEconomy,
|
|
latestEconomy,
|
|
upsertHouse,
|
|
listIdoc,
|
|
listHousesForAccounts,
|
|
upsertHouseRegistry,
|
|
removeHouse,
|
|
listHouses,
|
|
upsertChamp,
|
|
removeChamp,
|
|
clearChamps,
|
|
listChamps,
|
|
replaceChamps,
|
|
upsertPage,
|
|
removePage,
|
|
clearPages,
|
|
listPages,
|
|
replacePages,
|
|
upsertGuild,
|
|
removeGuild,
|
|
clearGuilds,
|
|
listGuilds,
|
|
replaceGuilds,
|
|
findGuildForActor,
|
|
listGuildsLedForAccounts,
|
|
upsertGovernor,
|
|
listGovernors,
|
|
listGovernorshipsForAccounts,
|
|
listGovernorHistory,
|
|
replaceGovernors,
|
|
setPresence,
|
|
latestPresence,
|
|
setRuleset,
|
|
getRuleset,
|
|
}
|