Files
Module-uo/server/model/shardState/shardState.db.js
wtclaude 99d1ca25a7
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s
feat(teams): ingest guild rank, and report every leader rather than one
The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).

`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.

The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.

## NULL rank is a real state, and it is load-bearing

The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:

  - the ingest stores NULL rather than defaulting to 0, which would be a
    demotion this code invented;
  - `leader` requires an integer rank >= 4, so absence is never leadership;
  - the leaders query compares on `rank`, and NULL is excluded by the comparison.

Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.

## Rank labels

Three sources, in order: a custom rank's literal string, then the operator's
cliloc table, then the five standard names. The last exists because the cliloc
table is populated only if someone ran the client-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.

`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.

The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.

## Verification

The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.

  stored:   0x1F5 rank=4  0x1F6 rank=3  0x1F7 rank=2  0x1F8 rank=1
            0x1F9 rank=NULL (the GameMaster)  0x2E0 rank=4
  provider: leaders = [0x1F5, 0x2E0]   <- two, which the board alone cannot express
            labels  = Leader / Warlord / Emissary / Member, with no cliloc table
            0x1F9   = not a leader, no label

9/9 checks. Suite 413 -> 421 tests, all passing.

Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:42:32 -05:00

419 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) ──────────────────────────────────────────
// `rank` is backticked wherever it is written, like `int` on shard_online: it is a
// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here
// and must not be relied on to.
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, 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.
//
// The rank columns are assigned unconditionally, NULL included. A member whose rank
// the shard withheld — a staff account, whose GuildRank getter reports Leader
// regardless of the truth — must go back to "not known" rather than keeping a rank
// from before they were promoted.
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.rank, r.rank_cliloc, r.rank_name, 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),
\`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc),
rank_name = VALUES(rank_name), 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,
}