From 080478c4a177bf20fb3eb43a555147613cac4aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:28:54 -0500 Subject: [PATCH] =?UTF-8?q?feat(shard):=20ingest=20Protocol=202.0=20boards?= =?UTF-8?q?=20=E2=80=94=20guilds,=20governors,=20presence,=20houses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/db/schema.sql | 98 ++++++++ server/src/model/shardState/shardState.db.js | 126 +++++++++- .../src/model/shardState/shardState.model.js | 231 ++++++++++++++++++ server/src/router/v1/public/public.routes.js | 44 ++++ .../src/router/v1/public/shard.controller.js | 70 +++++- server/src/utils/shardBroadcast.js | 9 + server/src/utils/shardIngest.js | 23 ++ server/src/utils/uoLinkClient.js | 9 + server/src/utils/uoLinkSocket.js | 27 ++ server/swagger/swagger-output.json | 159 ++++++++++++ server/test/shardIngest.protocol2.test.js | 93 +++++++ server/test/shardState.governorTerms.test.js | 77 ++++++ 12 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 server/test/shardIngest.protocol2.test.js create mode 100644 server/test/shardState.governorTerms.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 3559a9b..920f741 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -431,6 +431,87 @@ CREATE TABLE IF NOT EXISTS shard_pages ( INDEX idx_shard_pages_handled (handled) ) 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 -- 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 @@ -772,3 +853,20 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, -- 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 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; diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js index 5710f64..1f7ecff 100644 --- a/server/src/model/shardState/shardState.db.js +++ b/server/src/model/shardState/shardState.db.js @@ -107,12 +107,23 @@ const listHousesByAccounts = (accounts) => accounts.length === 0 ? Promise.resolve([]) : query( - `SELECT ${HOUSE_COLS} FROM shard_houses + `SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE owner_acct IN (${accounts.map(() => '?').join(', ')}) ORDER BY is_idoc DESC, updated_at DESC`, 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 ──────────────────────────────────────────────────────── const CHAMP_COLS = '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. 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 = { upsertOnline, removeOnline, @@ -171,6 +280,21 @@ module.exports = { upsertHouse, listIdocHouses, listHousesByAccounts, + removeHouse, + listRegistryHouses, + upsertGuild, + removeGuild, + clearGuilds, + listGuilds, + upsertGovernor, + listGovernors, + listGovernorshipsByAccounts, + currentGovernorTerm, + closeGovernorTerm, + openGovernorTerm, + listGovernorTerms, + setPresence, + latestPresence, upsertChamp, removeChamp, clearChamps, diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js index 1773b81..f851e93 100644 --- a/server/src/model/shardState/shardState.model.js +++ b/server/src/model/shardState/shardState.model.js @@ -148,6 +148,13 @@ function shapeHouse(r) { 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), @@ -166,6 +173,42 @@ async function listHousesForAccounts(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 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). async function listOnlineForAccounts(accounts) { const rows = await db.listOnlineByAccounts(accounts) @@ -287,6 +330,179 @@ async function replacePages(pages) { 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) { try { return JSON.parse(s) @@ -309,6 +525,9 @@ module.exports = { upsertHouse, listIdoc, listHousesForAccounts, + upsertHouseRegistry, + removeHouse, + listHouses, upsertChamp, removeChamp, clearChamps, @@ -319,4 +538,16 @@ module.exports = { clearPages, listPages, replacePages, + upsertGuild, + removeGuild, + clearGuilds, + listGuilds, + replaceGuilds, + upsertGovernor, + listGovernors, + listGovernorshipsForAccounts, + listGovernorHistory, + replaceGovernors, + setPresence, + latestPresence, } diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 846f44c..e172df5 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -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 } } } } } */ 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( '/shard/stream', // #swagger.tags = ['Public · Shard'] diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js index 6967007..4f00bc3 100644 --- a/server/src/router/v1/public/shard.controller.js +++ b/server/src/router/v1/public/shard.controller.js @@ -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). function stream(req, res) { 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, +} diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js index c453efc..067d496 100644 --- a/server/src/utils/shardBroadcast.js +++ b/server/src/utils/shardBroadcast.js @@ -36,6 +36,15 @@ const PUBLIC_KINDS = new Set([ // Champion-spawn board deltas — the public Champions page renders these live. 'champ.update', '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. diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js index 6a6c782..c537467 100644 --- a/server/src/utils/shardIngest.js +++ b/server/src/utils/shardIngest.js @@ -39,6 +39,8 @@ const LOGGED_KINDS = new Set([ 'server.hello', 'server.shutdown', '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) @@ -146,6 +148,27 @@ async function applyStateChange(event, deps) { case 'page.closed': await shardState.removePage(event.pageId) 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: // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and // broadcasting still happen in ingest(). diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index 9d487fe..26bca79 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -104,6 +104,11 @@ const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(li // our own store thereafter. const getChamps = () => call('/champs') 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 ────────────────────────────────────────────────────────────── const confirmLink = (code, websiteUserId) => @@ -142,6 +147,10 @@ module.exports = { getEconomy, getChamps, getPages, + getGuilds, + getGovernors, + getHouses, + getPresence, confirmLink, linkLookup, postTownCrier, diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js index 1e7e2a3..f84f8f1 100644 --- a/server/src/utils/uoLinkSocket.js +++ b/server/src/utils/uoLinkSocket.js @@ -75,6 +75,33 @@ async function backfill() { await shardState.replacePages(pages.data.pages) 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) { log.warn('backfill failed (continuing on live feed)', { message: err.message }) } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 34bb1ba..1c3a7c8 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": { "get": { "tags": [ diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js new file mode 100644 index 0000000..ae44452 --- /dev/null +++ b/server/test/shardIngest.protocol2.test.js @@ -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 +}) diff --git a/server/test/shardState.governorTerms.test.js b/server/test/shardState.governorTerms.test.js new file mode 100644 index 0000000..1b6a911 --- /dev/null +++ b/server/test/shardState.governorTerms.test.js @@ -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() +})