feat(shard): ingest Protocol 2.0 boards — guilds, governors, presence, houses

Phase 1 of the Protocol 2.0/2.1 integration: the read/ingest backend for the four
new uo-link boards, following the established champs/pages pattern (ingest → our
MariaDB + snapshot-on-reconnect + public SSE + token-free public endpoint).

- Schema: shard_guilds, shard_governors, shard_governor_terms, shard_presence;
  extend shard_houses with the house.update registry columns (owner_name,
  co_owners, friends, price, decay, in_registry) so the decay-transition and
  registry feeds share one house row without clobbering each other.
- Ingest: route guild.update/remove, city.update, presence.online,
  house.update/remove; log guild.join (real-time joins feed); region.enter is
  broadcast-only. All new public kinds added to the SSE allowlist.
- Governor term history captured from day one: on every observed governor CHANGE
  the open term is closed and a new one opened, idempotent so backfill/duplicate
  city.update never spawn spurious terms. votes stays NULL (the feed carries only
  candidate count, not tallies) — we never fabricate vote numbers.
- Client + backfill: getGuilds/getGovernors/getHouses/getPresence; snapshot each
  board on every WS (re)connect, independently guarded so an empty/failed board
  (e.g. no City Loyalty) never wipes another.
- Public endpoints: /shard/{guilds,governors,governors/:city/history,presence,houses}.
- Tests: ingest routing for all new kinds + governor term-capture idempotency
  (15 new; full suite 179/179). Swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 12:28:54 -05:00
parent 3b333b1b49
commit 080478c4a1
12 changed files with 964 additions and 2 deletions

View File

@@ -431,6 +431,87 @@ CREATE TABLE IF NOT EXISTS shard_pages (
INDEX idx_shard_pages_handled (handled) INDEX idx_shard_pages_handled (handled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
-- actor object flattened into leader_* columns; the full event is kept in
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
-- projection into our store so the public Guilds page survives a shard outage.
CREATE TABLE IF NOT EXISTS shard_guilds (
id INT NOT NULL PRIMARY KEY, -- in-game guild id
name VARCHAR(120) NULL,
abbr VARCHAR(24) NULL,
members INT NULL,
online INT NULL,
alliance VARCHAR(120) NULL,
leader_serial VARCHAR(20) NULL,
leader_name VARCHAR(120) NULL,
leader_acct VARCHAR(120) NULL,
leader_web_id INT NULL,
payload JSON NOT NULL, -- the full guild.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
-- flattened into columns; the full event is kept in `payload`. Empty on shards
-- that do not run the City Loyalty system.
CREATE TABLE IF NOT EXISTS shard_governors (
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
governor_serial VARCHAR(20) NULL,
governor_name VARCHAR(120) NULL,
governor_acct VARCHAR(120) NULL,
governor_web_id INT NULL,
elect_serial VARCHAR(20) NULL,
elect_name VARCHAR(120) NULL,
elect_acct VARCHAR(120) NULL,
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
candidates INT NULL,
auto_pick_at DATETIME NULL,
payload JSON NOT NULL, -- the full city.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Governor term history — the "who governed when" ledger behind the Governors
-- board. Captured from day one (history cannot be backfilled) on every observed
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
-- COUNT and election phase, not per-candidate tallies, so we record who governed
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
-- were all the governors of Britain?") reads this table.
CREATE TABLE IF NOT EXISTS shard_governor_terms (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
city VARCHAR(40) NOT NULL,
governor_serial VARCHAR(20) NULL,
governor_name VARCHAR(120) NULL,
governor_acct VARCHAR(120) NULL,
governor_web_id INT NULL,
started_at BIGINT NOT NULL, -- term start, epoch ms
ended_at BIGINT NULL, -- term end, epoch ms; NULL = current
votes INT NULL, -- not in the feed; reserved
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_shard_gov_terms_city (city, started_at),
INDEX idx_shard_gov_terms_open (city, ended_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
-- latest presence.online aggregate: total count plus per-facet and per-region
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
-- is the rolled-up headcount the public "Players Online" widget renders. The
-- time series, if ever needed, is available from GET /history?kind=presence.online.
CREATE TABLE IF NOT EXISTS shard_presence (
id INT PRIMARY KEY DEFAULT 1,
count INT NOT NULL DEFAULT 0,
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
t BIGINT NULL, -- snapshot time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot moderation core (Phase 2). These tables are owned by the bot -- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or -- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else -- writes them. They live in the same physical database as everything else
@@ -772,3 +853,20 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title,
-- already keeps the two tables consistent. -- already keeps the two tables consistent.
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL; ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL; ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
-- fields than the house.decay transition feed shard_houses was built for. Rather
-- than a second table for one entity, extend shard_houses: house.update writes the
-- registry columns below (owner display name, co-owner/friend counts, placement
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
-- upsert only touches its own columns, so the two feeds never clobber each other.
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
-- so the public Houses browser can list registered houses without pulling in rows
-- we only ever saw an IDOC transition for.
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;

View File

@@ -107,12 +107,23 @@ const listHousesByAccounts = (accounts) =>
accounts.length === 0 accounts.length === 0
? Promise.resolve([]) ? Promise.resolve([])
: query( : query(
`SELECT ${HOUSE_COLS} FROM shard_houses `SELECT ${HOUSE_REG_COLS} FROM shard_houses
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')}) WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY is_idoc DESC, updated_at DESC`, ORDER BY is_idoc DESC, updated_at DESC`,
accounts, accounts,
) )
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
// The registry columns extend HOUSE_COLS; a registry row is one we've seen via
// house.update (in_registry = 1), as opposed to a decay-only transition row.
const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry`
const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial])
// The full registered-house browser: every row we've seen via house.update.
const listRegistryHouses = () =>
query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`)
// ── Champion spawns ──────────────────────────────────────────────────────── // ── Champion spawns ────────────────────────────────────────────────────────
const CHAMP_COLS = const CHAMP_COLS =
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at' 'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
@@ -157,6 +168,104 @@ const clearPages = () => query('DELETE FROM shard_pages')
// Oldest-open first so the queue reads like a work list. // Oldest-open first so the queue reads like a work list.
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`) const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
const GUILD_COLS =
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
async function upsertGuild(id, fields) {
const cols = Object.keys(fields)
const allCols = ['id', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_guilds (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[id, ...cols.map((c) => fields[c])],
)
}
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
const GOV_COLS =
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
async function upsertGovernor(city, fields) {
const cols = Object.keys(fields)
const allCols = ['city', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_governors (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[city, ...cols.map((c) => fields[c])],
)
}
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)
// Cities whose current governor is one of the given game accounts (cross-link:
// does this user hold a governorship?). Empty list short-circuits.
const listGovernorshipsByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT ${GOV_COLS} FROM shard_governors
WHERE governor_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY city ASC`,
accounts,
)
// The single open term (ended_at IS NULL) for a city, if any.
async function currentGovernorTerm(city) {
const rows = await query(
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1',
[city],
)
return rows[0] || null
}
const closeGovernorTerm = (id, endedAt) =>
query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id])
const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) =>
query(
`INSERT INTO shard_governor_terms
(city, governor_serial, governor_name, governor_acct, governor_web_id, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt],
)
const listGovernorTerms = (city, limit) =>
query(
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?',
[city, limit],
)
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
async function setPresence({ count, byFacet, byRegion, t }) {
await query(
`INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet),
by_region = VALUES(by_region), t = VALUES(t)`,
[
Number.isFinite(count) ? count : 0,
byFacet ? JSON.stringify(byFacet) : null,
byRegion ? JSON.stringify(byRegion) : null,
Number.isFinite(t) ? t : null,
],
)
}
async function latestPresence() {
const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1')
return rows[0] || null
}
module.exports = { module.exports = {
upsertOnline, upsertOnline,
removeOnline, removeOnline,
@@ -171,6 +280,21 @@ module.exports = {
upsertHouse, upsertHouse,
listIdocHouses, listIdocHouses,
listHousesByAccounts, listHousesByAccounts,
removeHouse,
listRegistryHouses,
upsertGuild,
removeGuild,
clearGuilds,
listGuilds,
upsertGovernor,
listGovernors,
listGovernorshipsByAccounts,
currentGovernorTerm,
closeGovernorTerm,
openGovernorTerm,
listGovernorTerms,
setPresence,
latestPresence,
upsertChamp, upsertChamp,
removeChamp, removeChamp,
clearChamps, clearChamps,

View File

@@ -148,6 +148,13 @@ function shapeHouse(r) {
name: r.name, name: r.name,
ownerSerial: r.owner_serial, ownerSerial: r.owner_serial,
ownerAcct: r.owner_acct, 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, builtOn: r.built_on,
lastRefreshed: r.last_refreshed, lastRefreshed: r.last_refreshed,
isIdoc: Boolean(r.is_idoc), isIdoc: Boolean(r.is_idoc),
@@ -166,6 +173,42 @@ async function listHousesForAccounts(accounts) {
return rows.map(shapeHouse) 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 owner = data.owner || null
const fields = {
name: data.name ?? null,
owner_serial: owner ? owner.serial ?? null : null,
owner_acct: owner ? owner.acct ?? null : null,
owner_name: owner ? owner.name ?? null : null,
co_owners: data.coOwners ?? null,
friends: data.friends ?? null,
price: data.price ?? null,
decay: data.decay ?? null,
region: data.region ?? null,
map: data.map ?? null,
x: data.x ?? null,
y: data.y ?? null,
z: data.z ?? null,
built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
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). // Online players on the given game accounts (admin: a user's linked accounts).
async function listOnlineForAccounts(accounts) { async function listOnlineForAccounts(accounts) {
const rows = await db.listOnlineByAccounts(accounts) const rows = await db.listOnlineByAccounts(accounts)
@@ -287,6 +330,179 @@ async function replacePages(pages) {
for (const ev of pages || []) await upsertPage(ev) 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)
}
// ── 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 || null
const elect = ev.governorElect || null
await db.upsertGovernor(ev.city, {
governor_serial: gov ? gov.serial ?? null : null,
governor_name: gov ? gov.name ?? null : null,
governor_acct: gov ? gov.acct ?? null : null,
governor_web_id: gov ? gov.webId ?? null : null,
elect_serial: elect ? elect.serial ?? null : null,
elect_name: elect ? elect.name ?? null : null,
elect_acct: elect ? elect.acct ?? null : null,
election_phase: ev.electionPhase ?? null,
candidates: ev.candidates ?? null,
auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null,
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),
}
}
function safeJson(s) { function safeJson(s) {
try { try {
return JSON.parse(s) return JSON.parse(s)
@@ -309,6 +525,9 @@ module.exports = {
upsertHouse, upsertHouse,
listIdoc, listIdoc,
listHousesForAccounts, listHousesForAccounts,
upsertHouseRegistry,
removeHouse,
listHouses,
upsertChamp, upsertChamp,
removeChamp, removeChamp,
clearChamps, clearChamps,
@@ -319,4 +538,16 @@ module.exports = {
clearPages, clearPages,
listPages, listPages,
replacePages, replacePages,
upsertGuild,
removeGuild,
clearGuilds,
listGuilds,
replaceGuilds,
upsertGovernor,
listGovernors,
listGovernorshipsForAccounts,
listGovernorHistory,
replaceGovernors,
setPresence,
latestPresence,
} }

View File

@@ -184,6 +184,50 @@ publicRouter.get(
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ /* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getChamps, shard.getChamps,
) )
publicRouter.get(
'/shard/guilds',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
publicRouter.get(
'/shard/governors',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current town-governor board (City Loyalty)'
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGovernors,
)
publicRouter.get(
'/shard/governors/:city/history',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Governor term history for a city'
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
param('city').isString().isLength({ min: 1, max: 40 }),
query('limit').optional().isInt({ min: 1, max: 500 }),
validate,
shard.getGovernorHistory,
)
publicRouter.get(
'/shard/presence',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
shard.getPresence,
)
publicRouter.get(
'/shard/houses',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
publicRouter.get( publicRouter.get(
'/shard/stream', '/shard/stream',
// #swagger.tags = ['Public · Shard'] // #swagger.tags = ['Public · Shard']

View File

@@ -104,9 +104,77 @@ async function getChamps(req, res) {
} }
} }
// GET /public/shard/guilds — the current guild board. Served from our store;
// live via guild.update / guild.remove / guild.join on the public SSE stream.
async function getGuilds(req, res) {
try {
return res.json(await shardState.listGuilds())
} catch (err) {
log.error('shard.getGuilds', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream.
async function getGovernors(req, res) {
try {
return res.json(await shardState.listGovernors())
} catch (err) {
log.error('shard.getGovernors', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors/:city/history — the term ledger for one city
// (look-back: "who were all the governors of Britain?"), newest first.
async function getGovernorHistory(req, res) {
try {
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
} catch (err) {
log.error('shard.getGovernorHistory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/presence — the online-population aggregate (count + per-facet
// + per-region). Live via presence.online on the public SSE stream.
async function getPresence(req, res) {
try {
return res.json(await shardState.latestPresence())
} catch (err) {
log.error('shard.getPresence', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/houses — the house registry (every house we've seen via
// house.update). Live via house.update / house.remove on the public SSE stream.
async function getHouses(req, res) {
try {
return res.json(await shardState.listHouses())
} catch (err) {
log.error('shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/stream — public live-event SSE channel (safe kinds only). // GET /public/shard/stream — public live-event SSE channel (safe kinds only).
function stream(req, res) { function stream(req, res) {
broadcast.subscribe(req, res, 'public') broadcast.subscribe(req, res, 'public')
} }
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, stream } module.exports = {
getStatus,
getFeed,
getEconomy,
getOnline,
getIdoc,
getChamps,
getGuilds,
getGovernors,
getGovernorHistory,
getPresence,
getHouses,
stream,
}

View File

@@ -36,6 +36,15 @@ const PUBLIC_KINDS = new Set([
// Champion-spawn board deltas — the public Champions page renders these live. // Champion-spawn board deltas — the public Champions page renders these live.
'champ.update', 'champ.update',
'champ.remove', 'champ.remove',
// Protocol 2.0 boards — all public, rendered live on their respective pages.
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
'house.update',
'house.remove',
]) ])
// Open response streams per channel. // Open response streams per channel.

View File

@@ -39,6 +39,8 @@ const LOGGED_KINDS = new Set([
'server.hello', 'server.hello',
'server.shutdown', 'server.shutdown',
'server.crashed', 'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
]) ])
// Tracks the current shard boot id so a restart (changed bootId on server.hello) // Tracks the current shard boot id so a restart (changed bootId on server.hello)
@@ -146,6 +148,27 @@ async function applyStateChange(event, deps) {
case 'page.closed': case 'page.closed':
await shardState.removePage(event.pageId) await shardState.removePage(event.pageId)
return return
// ── Protocol 2.0 boards ──────────────────────────────────────────────
case 'guild.update':
await shardState.upsertGuild(event)
return
case 'guild.remove':
await shardState.removeGuild(event.id)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)
return
case 'presence.online':
await shardState.setPresence(event)
return
case 'house.update':
await shardState.upsertHouseRegistry(event)
return
case 'house.remove':
await shardState.removeHouse(event.serial)
return
// guild.join → logged (real-time feed); region.enter → broadcast-only.
default: default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest(). // broadcasting still happen in ingest().

View File

@@ -104,6 +104,11 @@ const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(li
// our own store thereafter. // our own store thereafter.
const getChamps = () => call('/champs') const getChamps = () => call('/champs')
const getPages = () => call('/pages') const getPages = () => call('/pages')
// Protocol 2.0 board projections — same snapshot-on-connect pattern.
const getGuilds = () => call('/guilds')
const getGovernors = () => call('/governors')
const getHouses = () => call('/houses')
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
// ── Commands ────────────────────────────────────────────────────────────── // ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) => const confirmLink = (code, websiteUserId) =>
@@ -142,6 +147,10 @@ module.exports = {
getEconomy, getEconomy,
getChamps, getChamps,
getPages, getPages,
getGuilds,
getGovernors,
getHouses,
getPresence,
confirmLink, confirmLink,
linkLookup, linkLookup,
postTownCrier, postTownCrier,

View File

@@ -75,6 +75,33 @@ async function backfill() {
await shardState.replacePages(pages.data.pages) await shardState.replacePages(pages.data.pages)
log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length }) log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length })
} }
// ── Protocol 2.0 boards ──────────────────────────────────────────────
// Same as champs/pages: snapshot the authoritative current state and
// reconcile our tables to it. Each call is independently guarded so a
// failed/absent board (e.g. no City Loyalty → empty /governors) never wipes
// another. Governors are NOT cleared before upsert (cities are fixed and the
// term-capture is idempotent, so a reconnect can't spawn spurious terms).
const guilds = await uoLinkClient.getGuilds()
if (guilds.ok && guilds.data && Array.isArray(guilds.data.guilds)) {
await shardState.replaceGuilds(guilds.data.guilds)
log.info('snapshotted guild board from /guilds', { count: guilds.data.guilds.length })
}
const governors = await uoLinkClient.getGovernors()
if (governors.ok && governors.data && Array.isArray(governors.data.cities)) {
await shardState.replaceGovernors(governors.data.cities)
log.info('snapshotted governor board from /governors', { count: governors.data.cities.length })
}
const houses = await uoLinkClient.getHouses()
if (houses.ok && houses.data && Array.isArray(houses.data.houses)) {
for (const ev of houses.data.houses) await shardIngest.ingest(ev, { fromBackfill: true })
log.info('snapshotted house registry from /houses', { count: houses.data.houses.length })
}
const presence = await uoLinkClient.getPresence()
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
await shardState.setPresence(presence.data)
log.info('snapshotted online population from /online', { count: presence.data.count })
}
} catch (err) { } catch (err) {
log.warn('backfill failed (continuing on live feed)', { message: err.message }) log.warn('backfill failed (continuing on live feed)', { message: err.message })
} }

View File

@@ -1492,6 +1492,165 @@
} }
} }
}, },
"/api/v1/public/shard/guilds": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "Current guild board (rosters, alliances, leaders)",
"description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.",
"responses": {
"200": {
"description": "Guilds, ordered by name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/governors": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "Current town-governor board (City Loyalty)",
"description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.",
"responses": {
"200": {
"description": "Cities, ordered by name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/governors/{city}/history": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "Governor term history for a city",
"description": "",
"parameters": [
{
"name": "city",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "City name, e.g. Britain."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Max terms (default 100, max 500)."
}
],
"responses": {
"200": {
"description": "Terms, newest first",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/presence": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "Online population aggregate (count + per-facet + per-region)",
"description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.",
"responses": {
"200": {
"description": "Population snapshot",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/houses": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "House registry (owner, co-owners, price, decay)",
"description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.",
"responses": {
"200": {
"description": "Houses, ordered by name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ShardHouse"
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/stream": { "/api/v1/public/shard/stream": {
"get": { "get": {
"tags": [ "tags": [

View File

@@ -0,0 +1,93 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../src/utils/shardIngest')
// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the
// methods the tested kinds touch need to be real; the rest are no-op async so
// ingest() never throws on an unrelated kind.
function makeDeps() {
const calls = {
guildUpsert: [], guildRemove: [],
governorUpsert: [],
presenceSet: [],
houseRegistry: [], houseRemove: [],
appended: [], broadcast: [],
}
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertGuild: async (ev) => { calls.guildUpsert.push(ev) },
removeGuild: async (id) => { calls.guildRemove.push(id) },
upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) },
setPresence: async (ev) => { calls.presenceSet.push(ev) },
upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) },
removeHouse: async (serial) => { calls.houseRemove.push(serial) },
// Present so any stray routing is a harmless no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
addEconomySample: noop,
},
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps)
assert.equal(deps.calls.guildUpsert.length, 1)
assert.equal(deps.calls.guildUpsert[0].id, 1042)
assert.equal(r.logged, false) // board state, not appended to shard_events
await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps)
assert.deepEqual(deps.calls.guildRemove, [1042])
})
test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'guild.join')
assert.equal(deps.calls.broadcast.length, 1)
})
test('city.update routes to upsertGovernor (which also captures term history)', async () => {
const deps = makeDeps()
await shardIngest.ingest(
{ kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps)
assert.equal(deps.calls.governorUpsert.length, 1)
assert.equal(deps.calls.governorUpsert[0].city, 'Britain')
})
test('presence.online routes to setPresence and is not logged', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps)
assert.equal(deps.calls.presenceSet.length, 1)
assert.equal(deps.calls.presenceSet[0].count, 42)
assert.equal(r.logged, false)
})
test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps)
assert.equal(deps.calls.houseRegistry.length, 1)
assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234')
await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps)
assert.deepEqual(deps.calls.houseRemove, ['0x40001234'])
})
test('region.enter is broadcast-only — not logged, no state side effect', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
})

View File

@@ -0,0 +1,77 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
// Term capture lives in the model (shardState.model.upsertGovernor →
// recordGovernorTransition) and talks to the db module. We exercise the real
// logic against an in-memory fake by monkeypatching the shared db module object
// (same instance the model require()s) — no DB, no mocking library.
const db = require('../src/model/shardState/shardState.db')
const model = require('../src/model/shardState/shardState.model')
let terms // in-memory shard_governor_terms
let nextId
const saved = {}
beforeEach(() => {
terms = []
nextId = 1
for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) {
saved[k] = db[k]
}
db.currentGovernorTerm = async (city) =>
terms.find((t) => t.city === city && t.ended_at === null) || null
db.closeGovernorTerm = async (id, endedAt) => {
const row = terms.find((t) => t.id === id)
if (row) row.ended_at = endedAt
}
db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => {
terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name,
governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null })
}
db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture
})
function restore() {
for (const k of Object.keys(saved)) db[k] = saved[k]
}
test('a repeated city.update with the same governor does NOT open a second term', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 })
const open = terms.filter((t) => t.ended_at === null)
assert.equal(terms.length, 1)
assert.equal(open.length, 1)
assert.equal(open[0].governor_serial, '0x1')
assert.equal(open[0].started_at, 100)
restore()
})
test('a governor change closes the old term and opens a new one', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 })
assert.equal(terms.length, 2)
const [first, second] = terms
assert.equal(first.governor_serial, '0x1')
assert.equal(first.ended_at, 300) // closed at the transition time
assert.equal(second.governor_serial, '0x2')
assert.equal(second.ended_at, null) // now current
assert.equal(second.started_at, 300)
restore()
})
test('a seat going vacant closes the term without opening a new one', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 })
assert.equal(terms.length, 1)
assert.equal(terms[0].ended_at, 400)
restore()
})
test('terms are tracked independently per city', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 })
await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op
assert.equal(terms.length, 2)
assert.equal(terms.filter((t) => t.ended_at === null).length, 2)
restore()
})