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

@@ -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.

View File

@@ -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().

View File

@@ -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,

View File

@@ -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 })
}