feat(rust): chat titles rank twenty-three conditions, and admins name the categories (PLAN_REDESIGNS §5, D172-D175, D209)
- Schema: fourteen title columns on rust_player_wipe_stats (the two best_* distances move by GREATEST, the rest are sums), rust_weapon_kills (kills by weapon prefab name) and rust_title_categories (the admin's title per category). purge.sql drops the two tables. - Ingest: player.tally's new fields, in one statement per frame, none for an older plugin's frame. - Titles: 23 categories plus playtime, each naming where it is read from (a sum, a MAX, npc_kills - animal_kills in signed arithmetic, or a named list). The bow/melee/blade/revolver/wood/ore/plants lists are one file, applied when a title is read. "NPC kills" ranks human NPCs only (D209). - A rule's text may be empty, meaning the category's title: the rule's own, then the admin's, then the default. Typed-only-markup is still refused. A rename forgets every server's cached answer. - Admin API: GET /admin/rust/title-categories and PUT /admin/rust/title-categories/:stat (empty text resets); the server list returns them too. Swagger fragment and routes.manifest.json regenerated (63 routes, against the pinned core). - Screen: every category in the rule form, the category's title as a placeholder, and a Category titles section with Save and Reset. The weapon lists are unverified until the rig probe runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -13,6 +13,7 @@ const core = require('../../core')
|
||||
const EVENTS = 'rust_events'
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
const GATHER = 'rust_gather_totals'
|
||||
const WEAPONS = 'rust_weapon_kills'
|
||||
const PLAYERS = 'rust_players'
|
||||
const WIPES = 'rust_wipes'
|
||||
const PRESENCE = 'rust_presence'
|
||||
@@ -132,6 +133,78 @@ async function addStats({ serverId, wipeId, steamId }, deltas = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tally's counts for the chat-title conditions (PLAN_REDESIGNS §5.1): the
|
||||
* wire field each is, and the column it adds to. All sums.
|
||||
*/
|
||||
const TITLE_SUMS = {
|
||||
animalKills: 'animal_kills',
|
||||
headshots: 'headshots',
|
||||
apcKills: 'apc_kills',
|
||||
heliKills: 'heli_kills',
|
||||
built: 'built',
|
||||
repaired: 'repaired',
|
||||
healed: 'healed',
|
||||
rockets: 'rockets',
|
||||
explosives: 'explosives',
|
||||
missions: 'missions',
|
||||
}
|
||||
|
||||
/** The two *best* fields and their columns: the longest single kill, never a sum. */
|
||||
const TITLE_BESTS = { bestPvpM: 'best_pvp_m', bestPveM: 'best_pve_m' }
|
||||
|
||||
/** Item categories the plugin sends under `crafted`, and the column each counts in. */
|
||||
const CRAFTED = { attire: 'crafted_attire', weapon: 'crafted_weapons' }
|
||||
|
||||
/**
|
||||
* Adds one tally's title counts to the player's row for the wipe. A sum adds;
|
||||
* a best is GREATEST of the stored and the sent, because a tally carries its
|
||||
* interval's maximum and two maxima added are not a distance. Nothing is
|
||||
* written when the tally carried none of them, so an older plugin's frame
|
||||
* costs no statement.
|
||||
*/
|
||||
async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) {
|
||||
if (!serverId || !steamId) return
|
||||
|
||||
const sums = {}
|
||||
for (const [field, col] of Object.entries(TITLE_SUMS)) sums[col] = Math.max(0, Math.floor(Number(frame[field]) || 0))
|
||||
|
||||
const crafted = frame.crafted && typeof frame.crafted === 'object' ? frame.crafted : {}
|
||||
for (const [category, col] of Object.entries(CRAFTED)) sums[col] = Math.max(0, Math.floor(Number(crafted[category]) || 0))
|
||||
|
||||
const bests = {}
|
||||
for (const [field, col] of Object.entries(TITLE_BESTS)) {
|
||||
const m = Number(frame[field])
|
||||
bests[col] = Number.isFinite(m) && m > 0 ? Math.min(Math.round(m * 10) / 10, 999999.9) : 0
|
||||
}
|
||||
|
||||
if (![...Object.values(sums), ...Object.values(bests)].some((v) => v > 0)) return
|
||||
|
||||
const cols = [...Object.keys(sums), ...Object.keys(bests)]
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen)
|
||||
VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
${Object.keys(sums).map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')},
|
||||
${Object.keys(bests).map((c) => `${c} = GREATEST(${c}, VALUES(${c}))`).join(',\n ')},
|
||||
last_seen = CURRENT_TIMESTAMP`,
|
||||
[serverId, wipeId || '', steamId, ...Object.values(sums), ...Object.values(bests)],
|
||||
)
|
||||
}
|
||||
|
||||
/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */
|
||||
async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) {
|
||||
if (!serverId || !steamId || !weapon || !(kills > 0)) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${WEAPONS} (server_id, wipe_id, steam_id, weapon, kills)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE kills = kills + VALUES(kills)`,
|
||||
[serverId, wipeId || '', steamId, String(weapon).slice(0, 64), kills],
|
||||
)
|
||||
}
|
||||
|
||||
async function addGathered({ serverId, wipeId, steamId }, resource, amount) {
|
||||
if (!serverId || !steamId || !resource || !(amount > 0)) return
|
||||
|
||||
@@ -312,6 +385,8 @@ module.exports = {
|
||||
touchPlayer,
|
||||
addStats,
|
||||
addGathered,
|
||||
addTitleStats,
|
||||
addWeaponKills,
|
||||
replacePresence,
|
||||
pruneEvents,
|
||||
recentEvents,
|
||||
|
||||
72
server/model/titles/lists.js
Normal file
72
server/model/titles/lists.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ── The named lists behind five title categories (PLAN_REDESIGNS §5.3, D174) ──
|
||||
//
|
||||
// "In one place, so a Rust update adds a weapon by editing a list." The plugin
|
||||
// sends every credited kill by weapon SHORT PREFAB NAME — the name the killfeed
|
||||
// already shows (`bow_hunting.entity`) — and every gathered resource by item
|
||||
// shortname (`sulfur.ore`). These lists are applied when a title is READ, so
|
||||
// editing one corrects the whole wipe and no plugin release is needed.
|
||||
//
|
||||
// Melee is a list too: the site cannot ask the game whether a prefab is a
|
||||
// `BaseMelee`. A blade is a melee weapon, so every blade is in both.
|
||||
//
|
||||
// UNVERIFIED: the weapon names below are from knowledge of the game, not yet
|
||||
// read off the rig's own item list (the RgWeapons probe). Replace them with the
|
||||
// probe's output before this ships.
|
||||
|
||||
const BLADES = [
|
||||
'machete.weapon',
|
||||
'salvaged_sword.entity',
|
||||
'longsword.entity',
|
||||
'salvaged_cleaver.entity',
|
||||
'knife_bone.entity',
|
||||
'knife.combat.entity',
|
||||
'butcherknife.entity',
|
||||
]
|
||||
|
||||
const lists = {
|
||||
bow: ['bow_hunting.entity', 'compound_bow.entity', 'crossbow.entity'],
|
||||
blade: BLADES,
|
||||
melee: [
|
||||
...BLADES,
|
||||
'bone_club.entity',
|
||||
'mace.entity',
|
||||
'spear_wooden.entity',
|
||||
'spear_stone.entity',
|
||||
'hatchet.entity',
|
||||
'stonehatchet.entity',
|
||||
'pickaxe.entity',
|
||||
'stone_pickaxe.entity',
|
||||
'salvaged_axe.entity',
|
||||
'salvaged_hammer.entity',
|
||||
'salvaged_icepick.entity',
|
||||
'rock.entity',
|
||||
'torch.entity',
|
||||
'paddle.entity',
|
||||
'pitchfork.entity',
|
||||
'sickle.entity',
|
||||
],
|
||||
revolver: ['revolver.entity', 'python.entity'],
|
||||
|
||||
// Gathering (D159: all three sources, already in `rust_gather_totals`).
|
||||
wood: ['wood'],
|
||||
ore: ['metal.ore', 'sulfur.ore', 'hq.metal.ore'],
|
||||
// Hemp picks up as cloth; a seed that comes with a plant is not the plant.
|
||||
plants: [
|
||||
'cloth',
|
||||
'corn',
|
||||
'pumpkin',
|
||||
'potato',
|
||||
'mushroom',
|
||||
'red.berry',
|
||||
'blue.berry',
|
||||
'green.berry',
|
||||
'yellow.berry',
|
||||
'white.berry',
|
||||
'black.berry',
|
||||
'rose',
|
||||
'orchid',
|
||||
'sunflower',
|
||||
],
|
||||
}
|
||||
|
||||
module.exports = lists
|
||||
@@ -58,4 +58,105 @@ async function saveSettings(serverId, { mode, max, rules }) {
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { RULES, listRules, listAllRules, getMode, listModes, saveSettings }
|
||||
// ── The standings a category ranks (PLAN_REDESIGNS §5) ────────────────────
|
||||
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
const WEAPONS = 'rust_weapon_kills'
|
||||
const GATHER = 'rust_gather_totals'
|
||||
const CATEGORIES = 'rust_title_categories'
|
||||
|
||||
/** Only these ever reach a column position in SQL: the names `titles.js` declares. */
|
||||
const COLUMN = /^[a-z_]+$/
|
||||
|
||||
/**
|
||||
* One category's standings for one wipe: `{ steamId, value }` rows, highest
|
||||
* first, ties broken by who was seen last — the leaderboard's own order, so a
|
||||
* rule's top N is what the web would show (§33.4 reading 2).
|
||||
*
|
||||
* @param {object} source a category's `source` from `titles.js`
|
||||
* @param {object} lists list name → the short names it holds (`lists.js`)
|
||||
*/
|
||||
async function standings({ serverId, wipeId, source, lists = {}, limit }) {
|
||||
const params = [serverId, wipeId]
|
||||
|
||||
if (source.sum || source.diff || source.best) {
|
||||
let value
|
||||
if (source.sum) value = `SUM(s.${source.sum})`
|
||||
else if (source.best) value = `MAX(s.${source.best})`
|
||||
// Signed: `npc_kills` is unsigned, and a row counted before `animal_kills`
|
||||
// existed must not wrap round to four billion.
|
||||
else value = `SUM(CAST(s.${source.diff[0]} AS SIGNED) - CAST(s.${source.diff[1]} AS SIGNED))`
|
||||
|
||||
const cols = [source.sum, source.best, ...(source.diff || [])].filter(Boolean)
|
||||
if (!cols.every((c) => COLUMN.test(c))) throw new Error('not a stats column')
|
||||
|
||||
return core.query(
|
||||
`SELECT s.steam_id AS steamId, ${value} AS value
|
||||
FROM ${STATS} s
|
||||
WHERE s.server_id = ? AND s.wipe_id = ?
|
||||
GROUP BY s.steam_id
|
||||
ORDER BY value DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit],
|
||||
)
|
||||
}
|
||||
|
||||
const table = source.weapons ? WEAPONS : GATHER
|
||||
const key = source.weapons ? 'weapon' : 'resource'
|
||||
const amount = source.weapons ? 'kills' : 'amount'
|
||||
const names = lists[source.weapons || source.gathered] || []
|
||||
|
||||
if (!names.length) return []
|
||||
|
||||
// The stats row is joined only for the tie-break; its key is the table's,
|
||||
// so one row joins at most one and the sum is not multiplied.
|
||||
return core.query(
|
||||
`SELECT t.steam_id AS steamId, SUM(t.${amount}) AS value
|
||||
FROM ${table} t
|
||||
LEFT JOIN ${STATS} s
|
||||
ON s.server_id = t.server_id AND s.wipe_id = t.wipe_id AND s.steam_id = t.steam_id
|
||||
WHERE t.server_id = ? AND t.wipe_id = ? AND t.${key} IN (${names.map(() => '?').join(', ')})
|
||||
GROUP BY t.steam_id
|
||||
ORDER BY value DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
[...params, ...names, limit],
|
||||
)
|
||||
}
|
||||
|
||||
/** The admin's category titles (D175), as stat → text. */
|
||||
async function categoryTitles() {
|
||||
const rows = await core.query(`SELECT stat, text FROM ${CATEGORIES}`)
|
||||
return Object.fromEntries(rows.map((r) => [r.stat, r.text]))
|
||||
}
|
||||
|
||||
/** The same, with who changed each and when, for the admin screen. */
|
||||
async function listCategories() {
|
||||
return core.query(`SELECT stat, text, updated_by AS updatedBy, updated_at AS updatedAt FROM ${CATEGORIES}`)
|
||||
}
|
||||
|
||||
async function setCategory(stat, text, userId = null) {
|
||||
await core.query(
|
||||
`INSERT INTO ${CATEGORIES} (stat, text, updated_by, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE text = VALUES(text), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP`,
|
||||
[stat, text, userId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Reset: the row goes, and the module's default is back. */
|
||||
async function clearCategory(stat) {
|
||||
await core.query(`DELETE FROM ${CATEGORIES} WHERE stat = ?`, [stat])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RULES,
|
||||
listRules,
|
||||
listAllRules,
|
||||
getMode,
|
||||
listModes,
|
||||
saveSettings,
|
||||
standings,
|
||||
categoryTitles,
|
||||
listCategories,
|
||||
setCategory,
|
||||
clearCategory,
|
||||
}
|
||||
|
||||
@@ -18,13 +18,52 @@
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
/** The stats a rule may rank, and the leaderboard sort and column each means. */
|
||||
/**
|
||||
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
|
||||
* conditions and their shipped titles, plus `playtime`, which predates them and
|
||||
* has no default title, so a playtime rule always carries its own text.
|
||||
*
|
||||
* `source` says where the standing is read from (`titles.db.js`):
|
||||
* `sum` a column of `rust_player_wipe_stats`, summed
|
||||
* `diff` one column less another — human NPCs are every NPC kill less the
|
||||
* animals (§5.2, D209), so the public NPC column keeps its meaning
|
||||
* `best` a column's MAX: the longest single kill (D174)
|
||||
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
|
||||
* `gathered` a named list's amount in `rust_gather_totals` (D159)
|
||||
*
|
||||
* An admin may rename any category that has a default (D175); the ids are what
|
||||
* a rule stores, so they never change.
|
||||
*/
|
||||
const STATS = {
|
||||
kills: { sort: 'kills', value: (row) => row.kills, label: 'kills' },
|
||||
npckills: { sort: 'npcKills', value: (row) => row.npcKills, label: 'NPC kills' },
|
||||
playtime: { sort: 'playtime', value: (row) => row.playtimeSec, label: 'playtime' },
|
||||
kills: { label: 'Players killed', title: 'Reaper', source: { sum: 'kills' } },
|
||||
npckills: { label: 'NPC kills', title: 'Exterminator', source: { diff: ['npc_kills', 'animal_kills'] } },
|
||||
animalkills: { label: 'Animal kills', title: 'Beastbane', source: { sum: 'animal_kills' } },
|
||||
bowkills: { label: 'Bow kills', title: 'Fletcher', source: { weapons: 'bow' } },
|
||||
meleekills: { label: 'Melee kills', title: 'Brawler', source: { weapons: 'melee' } },
|
||||
bladekills: { label: 'Blade kills', title: 'Duelist', source: { weapons: 'blade' } },
|
||||
revolverkills: { label: 'Revolver kills', title: 'Outlaw', source: { weapons: 'revolver' } },
|
||||
headshots: { label: 'Headshots', title: 'Crownbreaker', source: { sum: 'headshots' } },
|
||||
pvpdistance: { label: 'Longest player kill', title: 'Longshot', source: { best: 'best_pvp_m' } },
|
||||
pvedistance: { label: 'Longest NPC or animal kill', title: 'Marksman', source: { best: 'best_pve_m' } },
|
||||
apckills: { label: 'APC kills', title: 'Tankbreaker', source: { sum: 'apc_kills' } },
|
||||
helikills: { label: 'Helicopter kills', title: 'Rotorbane', source: { sum: 'heli_kills' } },
|
||||
wood: { label: 'Wood gathered', title: 'Timberwright', source: { gathered: 'wood' } },
|
||||
ore: { label: 'Ore gathered', title: 'Prospector', source: { gathered: 'ore' } },
|
||||
plants: { label: 'Plants gathered', title: 'Greenhand', source: { gathered: 'plants' } },
|
||||
attire: { label: 'Clothes crafted', title: 'Seamster', source: { sum: 'crafted_attire' } },
|
||||
weapons: { label: 'Weapons crafted', title: 'Armorer', source: { sum: 'crafted_weapons' } },
|
||||
built: { label: 'Structures built', title: 'Mason', source: { sum: 'built' } },
|
||||
repaired: { label: 'Structures repaired', title: 'Tinkerer', source: { sum: 'repaired' } },
|
||||
healed: { label: 'Players healed', title: 'Mender', source: { sum: 'healed' } },
|
||||
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
|
||||
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
|
||||
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
|
||||
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
|
||||
}
|
||||
|
||||
/** The categories an admin can rename: every one with a shipped title (D175). */
|
||||
const CATEGORY_IDS = Object.keys(STATS).filter((id) => STATS[id].title)
|
||||
|
||||
const MODES = ['first', 'all', 'upto']
|
||||
|
||||
const MAX_RULES = 10
|
||||
@@ -83,8 +122,13 @@ function validateSettings(body) {
|
||||
const topN = Number(r.topN)
|
||||
if (!Number.isInteger(topN) || topN < 1 || topN > MAX_TOP_N) errors.push(`rule ${n}: top must be from 1 to ${MAX_TOP_N}`)
|
||||
|
||||
// Empty means "the category's title" (D175) — for a category that has one.
|
||||
// A text that was typed and cleaned down to nothing is still refused: a
|
||||
// title made entirely of brackets is a mistake, not a request for the default.
|
||||
const typed = String(r.text === undefined || r.text === null ? '' : r.text).trim()
|
||||
const text = cleanText(r.text)
|
||||
if (!text) errors.push(`rule ${n}: the title needs some text`)
|
||||
const stat = STATS[r.stat]
|
||||
if (!text && (typed || !stat || !stat.title)) errors.push(`rule ${n}: the title needs some text`)
|
||||
else if (text.length > TEXT_MAX) errors.push(`rule ${n}: a title is at most ${TEXT_MAX} characters`)
|
||||
|
||||
const color = String(r.color || '').trim().toLowerCase()
|
||||
@@ -96,12 +140,49 @@ function validateSettings(body) {
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: { mode, max, rules: clean } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The title a category shows (D175): the admin's, if they wrote one, else the
|
||||
* shipped default. `overrides` is stat → the admin's text.
|
||||
*/
|
||||
function categoryTitle(stat, overrides = {}) {
|
||||
const own = overrides[stat]
|
||||
if (own) return own
|
||||
return STATS[stat] ? STATS[stat].title : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an admin's title for a category. Resolves `{ ok, text }` or
|
||||
* `{ ok: false, error }`. An empty text is not an error here — it is the Reset,
|
||||
* and the caller deletes the row.
|
||||
*/
|
||||
function validateCategory(stat, raw) {
|
||||
if (!CATEGORY_IDS.includes(stat)) return { ok: false, error: `no category ${stat}` }
|
||||
const typed = String(raw === undefined || raw === null ? '' : raw).trim()
|
||||
const text = cleanText(raw)
|
||||
if (typed && !text) return { ok: false, error: 'the title needs some text' }
|
||||
if (text.length > TEXT_MAX) return { ok: false, error: `a title is at most ${TEXT_MAX} characters` }
|
||||
return { ok: true, text }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules with their text settled, first that is set winning (§4.6): the rule's
|
||||
* own, the admin's for the category, the module's default. A rule that ends up
|
||||
* with none — only possible for a rule saved before its category lost a title —
|
||||
* is dropped rather than shown as an empty chip.
|
||||
*/
|
||||
function resolveRules(rules, overrides = {}) {
|
||||
return (rules || [])
|
||||
.map((r) => ({ ...r, text: r.text || categoryTitle(r.stat, overrides) || '' }))
|
||||
.filter((r) => r.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds which title.
|
||||
*
|
||||
* @param {Array} rules in precedence order: `{ stat, topN, text, color }`
|
||||
* @param {object} standings stat → leaderboard rows for the current wipe,
|
||||
* in the leaderboard's order, at least `topN` long
|
||||
* @param {Array} rules in precedence order: `{ stat, topN, text, color }`,
|
||||
* with the text already settled (`resolveRules`)
|
||||
* @param {object} standings stat → `{ steamId, value }` rows for the current
|
||||
* wipe, in the leaderboard's order, at least `topN` long
|
||||
* @param {object} options
|
||||
* @param {string} options.mode `first` · `all` · `upto`
|
||||
* @param {number} options.max how many `upto` shows
|
||||
@@ -111,10 +192,9 @@ function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
|
||||
const held = new Map()
|
||||
|
||||
for (const rule of rules || []) {
|
||||
const stat = STATS[rule.stat]
|
||||
if (!stat) continue
|
||||
if (!STATS[rule.stat]) continue
|
||||
|
||||
const rows = (standings[rule.stat] || []).filter((row) => Number(stat.value(row)) > 0).slice(0, rule.topN)
|
||||
const rows = (standings[rule.stat] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
|
||||
|
||||
for (const row of rows) {
|
||||
if (!held.has(row.steamId)) held.set(row.steamId, [])
|
||||
@@ -150,6 +230,10 @@ function digest(set) {
|
||||
|
||||
module.exports = {
|
||||
STATS,
|
||||
CATEGORY_IDS,
|
||||
categoryTitle,
|
||||
validateCategory,
|
||||
resolveRules,
|
||||
MODES,
|
||||
MAX_RULES,
|
||||
MAX_TOP_N,
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
// 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 lists = require('./lists')
|
||||
const titles = require('./titles')
|
||||
|
||||
/** How long one server's answer is reused. The same as the push loop's tick. */
|
||||
@@ -47,8 +47,40 @@ async function saveSettings(serverId, value) {
|
||||
forget(serverId)
|
||||
}
|
||||
|
||||
function forget(serverId) {
|
||||
for (const key of memo.keys()) if (key.startsWith(`${serverId} `)) memo.delete(key)
|
||||
/** Forgets one server's answer, or every server's when a category was renamed. */
|
||||
function forget(serverId = null) {
|
||||
for (const key of memo.keys()) if (serverId === null || key.startsWith(`${serverId} `)) memo.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories as the admin screen shows them (D175): each one's label, the
|
||||
* shipped default, the admin's own title if any, and what rules show now.
|
||||
*/
|
||||
async function categories() {
|
||||
const own = new Map((await db.listCategories()).map((r) => [r.stat, r]))
|
||||
return titles.CATEGORY_IDS.map((stat) => {
|
||||
const row = own.get(stat)
|
||||
const def = titles.STATS[stat]
|
||||
return {
|
||||
stat,
|
||||
label: def.label,
|
||||
default: def.title,
|
||||
text: row ? row.text : null,
|
||||
current: row ? row.text : def.title,
|
||||
updatedAt: row ? row.updatedAt : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or resets one category's title. An empty text is the Reset. Every
|
||||
* server's answer is forgotten, because every server's rules may use it, and
|
||||
* the next push carries the new name (§4.6: "from the next push").
|
||||
*/
|
||||
async function setCategory(stat, text, userId = null) {
|
||||
if (text) await db.setCategory(stat, text, userId)
|
||||
else await db.clearCategory(stat)
|
||||
forget()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,43 +96,42 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
|
||||
const hit = memo.get(key)
|
||||
if (hit && now - hit.at < MEMO_MS) return hit.held
|
||||
|
||||
const settings = await settingsFor(serverId)
|
||||
const [settings, overrides] = await Promise.all([settingsFor(serverId), db.categoryTitles()])
|
||||
const rules = titles.resolveRules(settings.rules, overrides)
|
||||
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))
|
||||
for (const r of rules) if (titles.STATS[r.stat]) 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 rows = await db.standings({ serverId, wipeId, source: titles.STATS[stat].source, lists, limit })
|
||||
// SUM() and MAX() come back as strings; the rules compare numbers.
|
||||
standings[stat] = rows.map((r) => ({ steamId: r.steamId, value: Number(r.value) || 0 }))
|
||||
}),
|
||||
)
|
||||
|
||||
const held = titles.evaluate(settings.rules, normalise(standings), settings)
|
||||
const held = titles.evaluate(rules, 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 }
|
||||
module.exports = {
|
||||
MEMO_MS,
|
||||
settingsFor,
|
||||
settingsByServer,
|
||||
saveSettings,
|
||||
heldFor,
|
||||
currentFor,
|
||||
forget,
|
||||
categories,
|
||||
setCategory,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user