The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.
server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.
Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.
The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.
One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.
Co-Authored-By: Claude <noreply@anthropic.com>
370 lines
15 KiB
JavaScript
370 lines
15 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`)
|
|
|
|
// 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,
|
|
}
|