Files
Module-uo/server/model/shardState/shardState.db.js
wtclaude 2fa4d87a40
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
feat(shard): ingest guild rosters and departures (protocol 4)
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00

406 lines
17 KiB
JavaScript

const { query } = require('../../core')
// Shared upsert builder for the shard-state tables. Each is keyed on a single
// primary column (`pkCol` = pk); `fields` carries only the columns the model
// wants to write, so a partial refresh touches nothing else. `coalesce` keeps
// the prior column value when the incoming one is NULL (used by shard_online so a
// vitals frame that omits acct/name doesn't blank what mob.login set); otherwise
// the incoming value wins (VALUES()).
function upsertRow(table, pkCol, pk, fields, { coalesce = false } = {}) {
const cols = Object.keys(fields)
const allCols = [pkCol, ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const rhs = coalesce
? (c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`
: (c) => `\`${c}\` = VALUES(\`${c}\`)`
const updates = cols.map(rhs).join(', ')
return query(
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[pk, ...cols.map((c) => fields[c])],
)
}
// ── Online players ─────────────────────────────────────────────────────────
const ONLINE_COLS =
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
// Upsert one online player. `fields` already prepared by the model (only the
// columns it wants to write); serial is required and is the primary key.
// COALESCE variant: a char.vitals frame that omits acct/name must not blank what
// mob.login set, so an incoming NULL keeps the prior column value.
const upsertOnline = (serial, fields) =>
upsertRow('shard_online', 'serial', serial, fields, { coalesce: true })
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
const clearOnline = () => query('DELETE FROM shard_online')
async function countOnline() {
const rows = await query('SELECT COUNT(*) AS n FROM shard_online')
return rows[0] ? Number(rows[0].n) : 0
}
const listOnline = () =>
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
// Online players on any of the given game accounts (admin: a user's linked
// accounts). Empty list short-circuits so we never emit `IN ()`.
const listOnlineByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT ${ONLINE_COLS} FROM shard_online
WHERE acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY name ASC`,
accounts,
)
// Staff roles whose online presence is shown on the public Shard page. Players
// who link an account are NOT surfaced publicly — only staff opt into visibility
// by virtue of being staff.
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
// Online players whose game account is linked to a STAFF website user. Joined
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
// effect immediately, regardless of whether the player has re-logged since
// linking, then through to users so only staff roles are surfaced publicly.
const listOnlineLinked = () => {
const cols = ONLINE_COLS.split(', ')
.map((c) => `o.${c}`)
.join(', ')
return query(
`SELECT ${cols}
FROM shard_online o
JOIN shard_account_links l ON l.account = o.acct
JOIN users u ON u.id = l.user_id
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
ORDER BY o.name ASC`,
PUBLIC_ONLINE_ROLES,
)
}
// ── Economy supply series ────────────────────────────────────────────────
const insertEconomy = ({ accounts, gold, t }) =>
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
accounts ?? null,
gold ?? null,
t,
])
const listEconomy = (limit) =>
query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit])
async function latestEconomy() {
const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1')
return rows[0] || null
}
// ── Houses / IDOC ────────────────────────────────────────────────────────
const HOUSE_COLS =
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
const listIdocHouses = () =>
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
// Houses owned by any of the given game accounts (admin: a user's linked
// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits.
const listHousesByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`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'
const upsertChamp = (serial, fields) => upsertRow('shard_champs', 'serial', serial, fields)
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
const clearChamps = () => query('DELETE FROM shard_champs')
// Ordered by name (matches the sidecar's /champs ordering).
const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
// ── Help-page (support) queue ──────────────────────────────────────────────
const PAGE_COLS =
'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
async function upsertPage(pageId, fields) {
const cols = Object.keys(fields)
const allCols = ['page_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_pages (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[pageId, ...cols.map((c) => fields[c])],
)
}
const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
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'
const upsertGuild = (id, fields) => upsertRow('shard_guilds', 'id', id, fields)
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`)
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, t'
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
// backfill replays stored frames on every reconnect), and a redelivery must be a
// no-op rather than a duplicate-key error.
const upsertGuildMembers = (rows) => {
if (!rows.length) return Promise.resolve()
const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
const params = rows.flatMap((r) => [r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player, r.t])
return query(
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
web_id = VALUES(web_id), is_player = VALUES(is_player), t = VALUES(t)`,
params,
)
}
const clearGuildMembers = (guildId) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
const removeGuildMember = (guildId, serial) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
const listGuildMembers = (guildId) =>
query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
guildId,
])
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
const findGuildLedByActor = (serial, acct) =>
query(
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
LIMIT 1`,
[serial ?? null, acct ?? null],
)
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
const listGuildsLedByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY name ASC`,
accounts,
)
// ── 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'
const upsertGovernor = (city, fields) => upsertRow('shard_governors', 'city', city, fields)
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
}
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
// Singleton, same shape as shard_presence: the shard re-emits the whole frame on
// every connect, so there is nothing to merge — the latest one wins outright.
async function setRuleset({ rev, expansion, payload, t }) {
await query(
`INSERT INTO shard_ruleset (id, rev, expansion, payload, t) VALUES (1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE rev = VALUES(rev), expansion = VALUES(expansion),
payload = VALUES(payload), t = VALUES(t)`,
[rev ?? null, expansion ?? null, payload, Number.isFinite(t) ? t : null],
)
}
async function getRuleset() {
const rows = await query(
'SELECT rev, expansion, payload, t, updated_at FROM shard_ruleset WHERE id = 1',
)
return rows[0] || null
}
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
// One row per point system. The shard only emits a system whose top N actually
// moved, so this is a sparse stream of overwrites; there is no delete, because
// the shard's set of systems is fixed at startup.
async function upsertPointsBoard({ system, name, nameCliloc, maxPoints, players, showOnGump, payload, t }) {
await query(
`INSERT INTO shard_points_boards
(system, name, name_cliloc, max_points, players, show_on_gump, payload, t)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), name_cliloc = VALUES(name_cliloc),
max_points = VALUES(max_points), players = VALUES(players),
show_on_gump = VALUES(show_on_gump), payload = VALUES(payload), t = VALUES(t)`,
[
system,
name ?? null,
Number.isFinite(nameCliloc) ? nameCliloc : null,
Number.isFinite(maxPoints) ? maxPoints : null,
Number.isFinite(players) ? players : null,
showOnGump ? 1 : 0,
payload,
Number.isFinite(t) ? t : null,
],
)
}
// Ordered by display name, falling back to the system key for a board whose name
// arrived as a bare cliloc — otherwise every unresolved board would sort together
// under NULL.
async function listPointsBoards() {
return query(
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
FROM shard_points_boards ORDER BY COALESCE(name, system), system`,
)
}
async function getPointsBoard(system) {
const rows = await query(
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
FROM shard_points_boards WHERE system = ?`,
[system],
)
return rows[0] || null
}
module.exports = {
upsertOnline,
removeOnline,
clearOnline,
countOnline,
listOnline,
listOnlineLinked,
listOnlineByAccounts,
insertEconomy,
listEconomy,
latestEconomy,
upsertHouse,
listIdocHouses,
listHousesByAccounts,
removeHouse,
listRegistryHouses,
upsertGuild,
removeGuild,
clearGuilds,
listGuilds,
upsertGuildMembers,
clearGuildMembers,
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,
listGovernors,
listGovernorshipsByAccounts,
currentGovernorTerm,
closeGovernorTerm,
openGovernorTerm,
listGovernorTerms,
setPresence,
latestPresence,
setRuleset,
getRuleset,
upsertPointsBoard,
listPointsBoards,
getPointsBoard,
upsertChamp,
removeChamp,
clearChamps,
listChamps,
upsertPage,
removePage,
clearPages,
listPages,
}