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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user