module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.
server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.
server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:
* Transitions. champ.update and city.update are full-state upserts, so without
a per-process tracker a sidecar reconnect reads as twenty spawns starting.
A FIRST sighting is never a transition.
* Thresholds. conditions.js compares a declared variable against a LITERAL, so
"within 24 hours of dismissal" is not expressible; and vendor.listing is a
sweep frame re-emitted on any price change, so per-frame would flood. The
crossing is tracked here and `hoursRemaining` is declared so an operator can
still narrow with `is at most`.
* The members audience. "The members of THIS guild" differs every firing, so
it travels on the envelope as recipientUserIds (Phase 6 decision 2).
**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.
Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).
coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.
39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.
Co-Authored-By: Claude <noreply@anthropic.com>
776 lines
27 KiB
JavaScript
776 lines
27 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,
|
|
}))
|
|
}
|
|
|
|
|
|
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
|
|
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
|
|
// through `shardVisibility`, which strips `acct` for anyone below admin, so
|
|
// building an audience out of it would either leak the projection's job into
|
|
// this one or silently resolve to nobody depending on who asked. These two go to
|
|
// the database for exactly the column they need and pass nothing else on.
|
|
const listGuildMemberAccounts = async (guildId) =>
|
|
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
|
|
|
|
const listGovernorAccounts = async () =>
|
|
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
|
|
|
|
function shapeGuild(r) {
|
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
|
return payload || {
|
|
kind: 'guild.update',
|
|
id: r.id,
|
|
name: r.name,
|
|
abbr: r.abbr,
|
|
members: r.members,
|
|
online: r.online,
|
|
alliance: r.alliance,
|
|
leader: r.leader_serial
|
|
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
|
|
: null,
|
|
t: r.t,
|
|
}
|
|
}
|
|
|
|
async function listGuilds() {
|
|
const rows = await db.listGuilds()
|
|
return rows.map(shapeGuild)
|
|
}
|
|
|
|
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
|
|
async function replaceGuilds(guilds) {
|
|
await db.clearGuilds()
|
|
for (const ev of guilds || []) await upsertGuild(ev)
|
|
}
|
|
|
|
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
|
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
|
// null rather than show a possibly-stale guess.
|
|
async function findGuildForActor({ serial, acct }) {
|
|
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
|
const g = rows[0]
|
|
if (!g) return null
|
|
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
|
}
|
|
|
|
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
|
async function listGuildsLedForAccounts(accounts) {
|
|
const rows = await db.listGuildsLedByAccounts(accounts)
|
|
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
|
}
|
|
|
|
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
|
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
|
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
|
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
|
|
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
|
|
async function upsertGovernor(ev) {
|
|
if (!ev || !ev.city) return
|
|
await recordGovernorTransition(ev)
|
|
const gov = ev.governor
|
|
const elect = ev.governorElect
|
|
await db.upsertGovernor(ev.city, {
|
|
governor_serial: orNull(gov?.serial),
|
|
governor_name: orNull(gov?.name),
|
|
governor_acct: orNull(gov?.acct),
|
|
governor_web_id: orNull(gov?.webId),
|
|
elect_serial: orNull(elect?.serial),
|
|
elect_name: orNull(elect?.name),
|
|
elect_acct: orNull(elect?.acct),
|
|
election_phase: orNull(ev.electionPhase),
|
|
candidates: orNull(ev.candidates),
|
|
auto_pick_at: toDate(ev.autoPickAt),
|
|
payload: JSON.stringify(ev),
|
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
|
})
|
|
}
|
|
|
|
// Close the open term and open a new one when the governor CHANGES. Idempotent:
|
|
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
|
|
// city.update events never spawn spurious terms).
|
|
async function recordGovernorTransition(ev) {
|
|
const gov = ev.governor || null
|
|
const newSerial = gov ? gov.serial ?? null : null
|
|
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
|
|
const open = await db.currentGovernorTerm(ev.city)
|
|
const openSerial = open ? open.governor_serial : null
|
|
if (open && openSerial === newSerial) return // unchanged — nothing to record
|
|
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
|
|
if (newSerial) {
|
|
await db.openGovernorTerm({
|
|
city: ev.city,
|
|
serial: newSerial,
|
|
name: gov.name ?? null,
|
|
acct: gov.acct ?? null,
|
|
webId: gov.webId ?? null,
|
|
startedAt: t,
|
|
})
|
|
}
|
|
}
|
|
|
|
function shapeGovernor(r) {
|
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
|
return payload || {
|
|
kind: 'city.update',
|
|
city: r.city,
|
|
governor: r.governor_serial
|
|
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
|
: null,
|
|
governorElect: r.elect_serial
|
|
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
|
|
: null,
|
|
electionPhase: r.election_phase,
|
|
candidates: r.candidates,
|
|
t: r.t,
|
|
}
|
|
}
|
|
|
|
async function listGovernors() {
|
|
const rows = await db.listGovernors()
|
|
return rows.map(shapeGovernor)
|
|
}
|
|
|
|
// Cities the given game accounts currently govern (cross-link badge).
|
|
async function listGovernorshipsForAccounts(accounts) {
|
|
const rows = await db.listGovernorshipsByAccounts(accounts)
|
|
return rows.map(shapeGovernor)
|
|
}
|
|
|
|
// Term history for a city (look-back), newest first.
|
|
async function listGovernorHistory(city, limit = 100) {
|
|
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
|
const rows = await db.listGovernorTerms(city, n)
|
|
return rows.map((r) => ({
|
|
city: r.city,
|
|
governor: r.governor_serial
|
|
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
|
: null,
|
|
startedAt: r.started_at == null ? null : Number(r.started_at),
|
|
endedAt: r.ended_at == null ? null : Number(r.ended_at),
|
|
votes: r.votes,
|
|
}))
|
|
}
|
|
|
|
// Upsert governors without clearing (cities are fixed, no remove event); term
|
|
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
|
|
async function replaceGovernors(cities) {
|
|
for (const ev of cities || []) await upsertGovernor(ev)
|
|
}
|
|
|
|
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
|
async function setPresence(ev) {
|
|
if (!ev) return
|
|
await db.setPresence({
|
|
count: ev.count,
|
|
byFacet: ev.byFacet || null,
|
|
byRegion: ev.byRegion || null,
|
|
t: ev.t,
|
|
})
|
|
}
|
|
|
|
async function latestPresence() {
|
|
const r = await db.latestPresence()
|
|
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
|
|
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
|
|
return {
|
|
count: Number(r.count) || 0,
|
|
byFacet: parse(r.by_facet),
|
|
byRegion: parse(r.by_region),
|
|
t: r.t == null ? null : Number(r.t),
|
|
}
|
|
}
|
|
|
|
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
|
//
|
|
// The whole frame is stored in `payload` and served back whole. Nothing is
|
|
// normalized out of it: it is a flat description of config read as one page, and
|
|
// splitting it into columns would mean a schema change every time the shard grows
|
|
// a new block. `rev` and `expansion` are hoisted only because they are cheap to
|
|
// index/display, following shard_champs' payload-plus-hoisted-columns pattern.
|
|
async function setRuleset(ev) {
|
|
if (!ev) return
|
|
await db.setRuleset({
|
|
rev: ev.rev ?? null,
|
|
expansion: ev.expansion ?? null,
|
|
payload: JSON.stringify(ev),
|
|
t: ev.t,
|
|
})
|
|
}
|
|
|
|
// The stored ruleset, or null when the shard has never published one (an old
|
|
// plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page
|
|
// says "not published yet" rather than rendering an empty ruleset as if the shard
|
|
// had no rules — so it is deliberately not smoothed into {}.
|
|
async function getRuleset() {
|
|
const r = await db.getRuleset()
|
|
if (!r) return null
|
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
|
if (!payload) return null
|
|
return { ...payload, updatedAt: r.updated_at }
|
|
}
|
|
|
|
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
|
//
|
|
// The whole frame is stored in `payload`; the columns beside it are hoisted for
|
|
// listing and ordering only. The top-N list deliberately stays inside the payload
|
|
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
|
|
// candidates.
|
|
async function upsertPointsBoard(ev) {
|
|
if (!ev || !ev.system) return
|
|
await db.upsertPointsBoard({
|
|
system: String(ev.system).slice(0, 48),
|
|
name: ev.nameString ?? null,
|
|
nameCliloc: ev.nameNumber,
|
|
maxPoints: ev.maxPoints,
|
|
players: ev.players,
|
|
showOnGump: ev.showOnGump !== false,
|
|
payload: JSON.stringify(ev),
|
|
t: ev.t,
|
|
})
|
|
}
|
|
|
|
// A stored frame plus the freshness stamp. `top` is normalized to an array so a
|
|
// caller never has to guard it — a board with nobody on it is a real state (a
|
|
// system nobody has scored in yet), distinct from a system that was never
|
|
// published at all, which is absent from the table entirely.
|
|
function shapePointsBoard(r) {
|
|
const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
|
|
return {
|
|
...payload,
|
|
system: r.system,
|
|
top: Array.isArray(payload.top) ? payload.top : [],
|
|
updatedAt: r.updated_at,
|
|
}
|
|
}
|
|
|
|
async function listPointsBoards() {
|
|
const rows = await db.listPointsBoards()
|
|
return rows.map(shapePointsBoard)
|
|
}
|
|
|
|
async function getPointsBoard(system) {
|
|
const r = await db.getPointsBoard(system)
|
|
return r ? shapePointsBoard(r) : null
|
|
}
|
|
|
|
function safeJson(s) {
|
|
try {
|
|
return JSON.parse(s)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
upsertOnline,
|
|
setOffline,
|
|
clearOnline,
|
|
onlineCount,
|
|
listOnline,
|
|
listOnlineLinked,
|
|
listOnlineForAccounts,
|
|
addEconomySample,
|
|
listEconomy,
|
|
latestEconomy,
|
|
upsertHouse,
|
|
listIdoc,
|
|
listHousesForAccounts,
|
|
upsertHouseRegistry,
|
|
removeHouse,
|
|
listHouses,
|
|
upsertChamp,
|
|
removeChamp,
|
|
clearChamps,
|
|
listChamps,
|
|
replaceChamps,
|
|
upsertPage,
|
|
removePage,
|
|
clearPages,
|
|
listPages,
|
|
replacePages,
|
|
upsertGuild,
|
|
removeGuild,
|
|
clearGuilds,
|
|
listGuilds,
|
|
upsertGuildRoster,
|
|
removeGuildMember,
|
|
listGuildMembers,
|
|
listGuildMemberAccounts,
|
|
listGovernorAccounts,
|
|
replaceGuilds,
|
|
findGuildForActor,
|
|
listGuildsLedForAccounts,
|
|
upsertGovernor,
|
|
listGovernors,
|
|
listGovernorshipsForAccounts,
|
|
listGovernorHistory,
|
|
replaceGovernors,
|
|
setPresence,
|
|
latestPresence,
|
|
setRuleset,
|
|
getRuleset,
|
|
upsertPointsBoard,
|
|
listPointsBoards,
|
|
getPointsBoard,
|
|
}
|