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>
406 lines
17 KiB
JavaScript
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,
|
|
}
|