Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.
Server
- shard_points_boards: one row per system, keyed by the shard's PointsType
name. The top-N list stays inside `payload` — a fixed-size list read whole,
exactly like shard_governors.candidates. Normalizing into an entries table
buys nothing until something needs a per-character reverse lookup, and a
character's own standings already ride inside char.profile.
- shardIngest routes points.board to upsertPointsBoard and deliberately does
NOT log it: this is board state like guild.update, and the shard emits a
frame every time anyone's score moves a top ten.
- uoLinkSocket backfills /points through snapshot() with ingestEach rather
than a replace*: there is no points.remove and the system set is fixed, so
upserting IS the reconciliation, and a system the operator later excludes
keeps its last-known board rather than vanishing.
- GET /public/shard/points and /points/:system behind
requireFeature('leaderboards'), both projected per §3.6.1. :system is
constrained to an identifier before any query runs; 404 for a system never
published, distinct from a published board nobody has scored in (200, empty
top).
The leaderboards field rule now keys on `name`, not `characterName`
Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
projectValue matches on the LITERAL JSON key and the wire key is `name`. As
written the rule was inert: an admin tightening character names would have got
no enforcement and no error — precisely the failure §3.6.1 records for the
flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
back, and the admin panel's FIELD_LABEL carries the meaning instead.
Client
- routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
describes ONE system, so live frames merge over the fetched set by system
key rather than replacing it wholesale the way the ruleset does. Filter
matches board name, system key, or any ranked player — the last is what
makes it useful ("where do I appear?").
- A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
PlayerCharacter and AdminCharacter.
- Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
system key when nameString is null. Neither is defensive padding: on a real
shard uncapped and cliloc-only names are the majority case.
Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.
605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
// 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`)
|
|
|
|
// 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,
|
|
findGuildLedByActor,
|
|
listGuildsLedByAccounts,
|
|
upsertGovernor,
|
|
listGovernors,
|
|
listGovernorshipsByAccounts,
|
|
currentGovernorTerm,
|
|
closeGovernorTerm,
|
|
openGovernorTerm,
|
|
listGovernorTerms,
|
|
setPresence,
|
|
latestPresence,
|
|
setRuleset,
|
|
getRuleset,
|
|
upsertPointsBoard,
|
|
listPointsBoards,
|
|
getPointsBoard,
|
|
upsertChamp,
|
|
removeChamp,
|
|
clearChamps,
|
|
listChamps,
|
|
upsertPage,
|
|
removePage,
|
|
clearPages,
|
|
listPages,
|
|
}
|