// ── Chat titles, read against the live standings (phase 17) ──────────────── // // `titles.js` decides; this file fetches what it decides from. One answer per // server serves three readers — the push to the game, the web leaderboard and // the app's — so it is remembered briefly: the leaderboard is polled by every // open page, and each rule is a leaderboard query of its own. const eventsDb = require('../events/events.db') const serversDb = require('../servers/servers.db') const db = require('./titles.db') const titles = require('./titles') /** How long one server's answer is reused. The same as the push loop's tick. */ const MEMO_MS = 30 * 1000 const memo = new Map() /** A server's settings as the admin form reads them. */ async function settingsFor(serverId) { const [mode, rules] = await Promise.all([db.getMode(serverId), db.listRules(serverId)]) return shapeSettings(mode, rules) } function shapeSettings(mode, rules) { return { mode: titles.normaliseMode(mode && mode.mode), max: mode && Number(mode.max) ? Number(mode.max) : 2, rules: rules.map((r) => ({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })), } } /** Every server's settings, keyed by id, for the admin server list. */ async function settingsByServer() { const [modes, rules] = await Promise.all([db.listModes(), db.listAllRules()]) const out = new Map(modes.map((m) => [m.serverId, shapeSettings(m, [])])) for (const r of rules) { const s = out.get(r.serverId) if (s) s.rules.push({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color }) } return out } async function saveSettings(serverId, value) { await db.saveSettings(serverId, value) forget(serverId) } function forget(serverId) { for (const key of memo.keys()) if (key.startsWith(`${serverId} `)) memo.delete(key) } /** * Who holds which title on one server right now: a Map of Steam id to * `[{ text, color }]`, after the server's mode. Empty with no rules, and empty * with no current wipe — a title ranks the current wipe (D135), and a server that * has never said which wipe it is on has none to rank. */ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) { if (!wipeId) return new Map() const key = `${serverId} ${wipeId}` const hit = memo.get(key) if (hit && now - hit.at < MEMO_MS) return hit.held const settings = await settingsFor(serverId) const standings = {} // One query per STAT, at the deepest top N any rule on it asks for, rather // than one per rule: two rules on kills read the same rows. const depth = new Map() for (const r of settings.rules) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN)) await Promise.all( [...depth.entries()].map(async ([stat, limit]) => { standings[stat] = await eventsDb.leaderboard({ serverId, wipeId, sort: titles.STATS[stat].sort, limit }) }), ) const held = titles.evaluate(settings.rules, normalise(standings), settings) memo.set(key, { at: now, held }) return held } /** The leaderboard's rows carry numbers as strings from SUM(); the rules compare numbers. */ function normalise(standings) { const out = {} for (const [stat, rows] of Object.entries(standings)) { out[stat] = rows.map((r) => ({ steamId: r.steamId, kills: Number(r.kills) || 0, npcKills: Number(r.npcKills) || 0, playtimeSec: Number(r.playtimeSec) || 0, })) } return out } /** The same, for a server by id, reading its current wipe from its last report. */ async function currentFor(serverId) { const state = await serversDb.getState(serverId) return heldFor(serverId, { wipeId: state && state.wipeId ? state.wipeId : null }) } module.exports = { MEMO_MS, settingsFor, settingsByServer, saveSettings, heldFor, currentFor, forget }