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:
2026-09-28 23:32:59 -05:00
parent afb16112b0
commit aea94b5d7a
18 changed files with 1150 additions and 65 deletions

View File

@@ -19,6 +19,11 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- The chat-title conditions (PLAN_REDESIGNS §5). No foreign keys; the stats
-- columns go with their table below.
DROP TABLE IF EXISTS rust_title_categories;
DROP TABLE IF EXISTS rust_weapon_kills;
-- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before
-- `rust_permgroups`, which they reference.
DROP TABLE IF EXISTS rust_perm_exceptions;

View File

@@ -958,10 +958,13 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_once_at DATETIME NULL;
-- time they are pushed or shown, so nothing rolls them over at a wipe and
-- nothing can go stale (`model/titles`).
--
-- stat `kills` · `npckills` · `playtime`
-- stat one of the category ids in `model/titles/titles.js` (`kills`,
-- `playtime` and, since PLAN_REDESIGNS §5, twenty-two more)
-- top_n 1–10
-- text at most 24 characters, with `[ ] < > { }` stripped on save so an
-- operator's title can carry no markup of its own (§33.4)
-- operator's title can carry no markup of its own (§33.4). Empty
-- means "the category's title" (D175), except for `playtime`,
-- which has none and always carries its own
-- color `#rrggbb`
-- position the operator's order, which is also precedence (D136)
CREATE TABLE IF NOT EXISTS rust_title_rules (
@@ -1163,3 +1166,58 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS perm_policy VARCHAR(16) NOT NU
-- `split` (D190 gave a server its own copy of a shared group, and says so in
-- case the change was meant for every server).
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS direction VARCHAR(16) NOT NULL DEFAULT 'added';
-- ── The chat-title conditions (PLAN_REDESIGNS §5, D172–D175, D209) ────────
--
-- Twenty-three things a title can rank, counted by the plugin into
-- `player.tally` and kept here as more of the permanent record. They feed
-- titles only: the public leaderboard keeps its columns (§5.5).
--
-- `npc_kills` keeps counting every NPC AND animal, as it always has, so the
-- public NPC column does not change meaning mid-wipe (§5.2). `animal_kills`
-- counts the animals again, and a title's "NPC kills" is the difference (D209).
--
-- Every column is a sum except the two `best_*`, which hold the longest single
-- kill in metres and move by GREATEST at ingest: a tally carries its interval's
-- maximum, and adding two maxima is not a distance.
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS animal_kills INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS headshots INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS best_pvp_m DECIMAL(7,1) NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS best_pve_m DECIMAL(7,1) NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS apc_kills INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS heli_kills INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crafted_attire INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crafted_weapons INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS built INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS repaired INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS healed INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS rockets INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS explosives INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS missions INT UNSIGNED NOT NULL DEFAULT 0;
-- Every credited kill by the weapon's short prefab name, the name the killfeed
-- already shows (§5.3). Shaped like `rust_gather_totals`, and for the same
-- reason: the bow, melee, blade and revolver lists are applied when a title is
-- READ, so editing a list corrects the whole wipe and a new Rust weapon needs no
-- plugin release.
CREATE TABLE IF NOT EXISTS rust_weapon_kills (
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
weapon VARCHAR(64) NOT NULL,
kills INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, wipe_id, steam_id, weapon),
KEY idx_rust_weapon_kills_top (server_id, wipe_id, weapon)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The admin's title for a category, for the whole site (D175). A row exists
-- only while the admin has one; deleting it brings the module's default back.
-- A rule's own text still wins over both (§5.5), and a rule saved with no text
-- means "the category's title", which is how a rename reaches existing rules.
CREATE TABLE IF NOT EXISTS rust_title_categories (
stat VARCHAR(16) NOT NULL PRIMARY KEY,
text VARCHAR(24) NOT NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -149,6 +149,15 @@ async function apply(serverId, item, server = null) {
for (const [resource, amount] of Object.entries(gathered)) {
await db.addGathered(at, resource, Number(amount) || 0)
}
// Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Deltas
// too, except the two distances, which are this interval's best.
await db.addTitleStats(at, frame)
const weapons = frame.weaponKills && typeof frame.weaponKills === 'object' ? frame.weaponKills : {}
for (const [weapon, kills] of Object.entries(weapons)) {
await db.addWeaponKills(at, weapon, Math.floor(Number(kills) || 0))
}
break
}

View File

@@ -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,

View 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

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -26,15 +26,22 @@ const log = core.logger('admin')
async function listServers(req, res) {
try {
const [rows, settings] = await Promise.all([servers.listForAdmin(), titlesModel.settingsByServer()])
const [rows, settings, categories] = await Promise.all([
servers.listForAdmin(),
titlesModel.settingsByServer(),
titlesModel.categories(),
])
// Phase 17: each server's chat titles, and what the last push of them found.
// PLAN_REDESIGNS §5.5: the category titles too, since a rule with no text of
// its own shows its category's, and the list says which that is.
res.json({
servers: rows.map((row) => ({
...row,
titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
titlePush: titleSync.lastPush(row.id),
})),
titleCategories: categories,
})
} catch (err) {
log.error('failed to read the server list', { error: err.message })
@@ -262,6 +269,45 @@ async function putTitles(req, res) {
}
}
/** The title categories (D175): each one's default, the admin's own, and what shows. */
async function getTitleCategories(req, res) {
try {
return res.json({ categories: await titlesModel.categories() })
} catch (err) {
log.error('failed to read the title categories', { error: err.message })
return res.status(500).json({ message: 'Failed to read the title categories' })
}
}
/**
* Rename one category for the whole site (D175), or reset it with an empty
* text. Every rule of that category without its own text shows the new title
* on every server from the next push.
*/
async function putTitleCategory(req, res) {
const { stat } = req.params
try {
const checked = titles.validateCategory(stat, req.body.text)
if (!checked.ok) {
const missing = !titles.CATEGORY_IDS.includes(stat)
return res.status(missing ? 404 : 400).json({ message: checked.error, errors: [checked.error] })
}
await titlesModel.setCategory(stat, checked.text, req.user ? req.user.id : null)
await core.activity.log({
req,
action: checked.text ? 'rust.titles.category.set' : 'rust.titles.category.reset',
detail: { stat, text: checked.text || null },
})
return res.json({ categories: await titlesModel.categories() })
} catch (err) {
log.error('failed to save a title category', { stat, error: err.message })
return res.status(500).json({ message: 'Failed to save the category title' })
}
}
/**
* Which optional mods one server has loaded right now, read live from the game
* (`integrations` on `server.status`). Live, because an operator installs
@@ -320,6 +366,8 @@ module.exports = {
fetchMap,
renderMap,
putTitles,
getTitleCategories,
putTitleCategory,
integrations,
getVoice,
putVoice,

View File

@@ -151,7 +151,7 @@ adminRustRouter.put(
'/servers/:id/titles',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Set a server’s chat titles'
// #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.'
// #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by one of the title categories (`RustTitleSettings`) and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. A rule’s `text` may be empty, meaning the category’s title (the admin’s, else the module’s default); `playtime` has no default and needs its own. A text that was typed but is only markup is refused. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustTitleSettings" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the server’s settings as stored' } */
@@ -167,6 +167,33 @@ adminRustRouter.put(
admin.putTitles,
)
adminRustRouter.get(
'/title-categories',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The chat-title categories and their titles'
// #swagger.description = 'Every category a title rule can rank that has a shipped title (PLAN_REDESIGNS §5, D172), with its label, the module’s default, the admin’s own title if they set one, and `current`, what a rule with no text of its own shows. The admin’s title is for the whole site (D175).'
/* #swagger.responses[200] = { description: 'The categories', content: { "application/json": { schema: { type: 'object', properties: { categories: { type: 'array', items: { $ref: "#/components/schemas/RustTitleCategory" } } } } } } } */
requireRole('admin'),
admin.getTitleCategories,
)
adminRustRouter.put(
'/title-categories/:stat',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Rename a chat-title category, or reset it'
// #swagger.description = 'Sets the site’s own title for one category (D175): at most 24 characters, markup characters taken out as a rule’s are. An empty `text` resets the category to the module’s default. Every rule of this category with no text of its own shows the new title on every server from the next push; a rule that carries its own text keeps it. Answers the whole list.'
// #swagger.parameters['stat'] = { in: 'path', required: true, description: 'The category id, for example animalkills', schema: { type: 'string' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { text: { type: 'string', maxLength: 24, example: 'Hunter' } } } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers every category' } */
/* #swagger.responses[400] = { description: 'The title is too long, or only markup' } */
/* #swagger.responses[404] = { description: 'No such category' } */
requireRole('admin'),
param('stat').isString().isLength({ min: 1, max: 16 }),
body('text').optional({ nullable: true }).isString().withMessage('text is a string'),
validate,
admin.putTitleCategory,
)
adminRustRouter.get(
'/servers/:id/integrations',
// #swagger.tags = ['Admin · Rust']

View File

@@ -19,6 +19,10 @@
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
// That is uniform across core's committed spec and is the house shape.
// The title categories' ids, so the enum below cannot drift from what the
// validator accepts. `titles.js` is pure and needs nothing from core.
const titles = require('../model/titles/titles')
module.exports = {
tags: [
{
@@ -180,12 +184,17 @@ module.exports = {
items: {
type: 'object',
properties: {
stat: { type: 'string', enum: ['kills', 'npckills', 'playtime'], example: 'kills' },
stat: {
type: 'string',
enum: Object.keys(titles.STATS),
description: 'The category the rule ranks (PLAN_REDESIGNS §5). `npckills` is human NPCs only (D209); `pvpdistance` and `pvedistance` rank the longest single kill; `playtime` has no default title.',
example: 'kills',
},
topN: { type: 'integer', minimum: 1, maximum: 10, example: 1 },
text: {
type: 'string',
maxLength: 24,
description: 'The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own.',
description: 'The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own. Empty means the category’s title (D175), except for `playtime`.',
example: 'Top Killer',
},
color: { type: 'string', example: '#ff8800' },
@@ -194,6 +203,18 @@ module.exports = {
},
},
},
RustTitleCategory: {
type: 'object',
description: 'One chat-title category (PLAN_REDESIGNS §5.5, D175) and the title a rule of it shows when the rule has none of its own.',
properties: {
stat: { type: 'string', example: 'animalkills' },
label: { type: 'string', example: 'Animal kills' },
default: { type: 'string', description: 'The module’s shipped title.', example: 'Beastbane' },
text: { type: 'string', nullable: true, description: 'The admin’s own title for the whole site, or null.', example: 'Hunter' },
current: { type: 'string', description: 'What shows: `text` when set, else `default`.', example: 'Hunter' },
updatedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
RustIntegrations: {
type: 'object',
description: 'Which optional mods a server has loaded right now (GET /admin/rust/servers/{id}/integrations), read live from the game.',

View File

@@ -44,14 +44,14 @@ withCore()
const titles = require('../model/titles/titles')
const chatStyle = require('../model/permissions/chatStyle')
const row = (steamId, stats) => ({ steamId, kills: 0, npcKills: 0, playtimeSec: 0, ...stats })
const row = (steamId, value = 0) => ({ steamId, value })
// ── Titles ─────────────────────────────────────────────────────────────────
test('a title rule counts only a stat above zero, so a fresh wipe gives no titles (§33.4 reading 1)', () => {
const held = titles.evaluate(
[{ stat: 'kills', topN: 3, text: 'Killer', color: '#ff0000' }],
{ kills: [row('1', { kills: 5 }), row('2', { kills: 0 }), row('3', { kills: 0 })] },
{ kills: [row('1', 5), row('2', 0), row('3', 0)] },
)
assert.deepStrictEqual([...held.keys()], ['1'])
@@ -62,7 +62,7 @@ test('a title rule counts only a stat above zero, so a fresh wipe gives no title
test('ties follow the leaderboard order: a rule’s top N is the first N rows it was given (reading 2)', () => {
const held = titles.evaluate(
[{ stat: 'playtime', topN: 2, text: 'Regular', color: '#00ff00' }],
{ playtime: [row('9', { playtimeSec: 60 }), row('4', { playtimeSec: 60 }), row('7', { playtimeSec: 60 })] },
{ playtime: [row('9', 60), row('4', 60), row('7', 60)] },
)
assert.deepStrictEqual([...held.keys()], ['9', '4'])
})
@@ -74,9 +74,9 @@ test('the mode keeps the first rule met, every rule, or up to N — in the opera
{ stat: 'playtime', topN: 1, text: 'Regular', color: '#0000ff' },
]
const standings = {
kills: [row('1', { kills: 3 })],
npckills: [row('1', { npcKills: 2 })],
playtime: [row('1', { playtimeSec: 9 })],
kills: [row('1', 3)],
npckills: [row('1', 2)],
playtime: [row('1', 9)],
}
const texts = (mode, max) => titles.evaluate(rules, standings, { mode, max }).get('1').map((t) => t.text)

View File

@@ -0,0 +1,221 @@
// ── The chat-title conditions (PLAN_REDESIGNS §5, D172–D175, D209) ─────────
//
// Four things here, each a mistake that would look right in review:
//
// • **a best is not a sum.** The two distance columns take GREATEST at ingest;
// adding two intervals' maxima would give a kill nobody made.
// • **"NPC kills" subtracts in SIGNED arithmetic.** `npc_kills` is unsigned,
// and a row counted before `animal_kills` existed must not wrap to 4 billion.
// • **a rename reaches a rule with no text.** The rule's own text wins, then
// the admin's category title, then the default (§4.6's three layers).
// • **a category's title is never a rule's text by accident.** An empty text is
// "use the category's", and a typed title made of markup is still refused.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return {
statements,
touching: (table) => statements.filter((s) => s.sql.includes(table)),
ctx,
}
}
withRecorder()
const titles = require('../model/titles/titles')
const lists = require('../model/titles/lists')
const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over })
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
// ── The categories ────────────────────────────────────────────────────────
test('twenty-three categories carry a default title, and playtime carries none (D172)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 23)
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
assert.strictEqual(titles.STATS.playtime.title, null)
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')
assert.strictEqual(titles.STATS.missions.title, 'Wayfarer')
// Every id fits the column a rule stores it in.
for (const id of Object.keys(titles.STATS)) assert.ok(id.length <= 16, id)
// Every list a category names exists, and is not empty.
for (const { source } of Object.values(titles.STATS)) {
const name = source.weapons || source.gathered
if (name) assert.ok(Array.isArray(lists[name]) && lists[name].length, name)
}
})
test('"NPC kills" is human NPCs: every NPC kill less the animals (D209)', () => {
assert.deepStrictEqual(titles.STATS.npckills.source, { diff: ['npc_kills', 'animal_kills'] })
})
test('every blade is also a melee weapon (§5.3)', () => {
for (const blade of lists.blade) assert.ok(lists.melee.includes(blade), blade)
})
// ── Rules and their text ──────────────────────────────────────────────────
test('a rule may leave its text empty when its category has a title, and not for playtime (D175)', () => {
const ok = titles.validateSettings({ rules: [{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }] })
assert.strictEqual(ok.ok, true)
assert.strictEqual(ok.value.rules[0].text, '')
const playtime = titles.validateSettings({ rules: [{ stat: 'playtime', topN: 1, text: '', color: '#00ff00' }] })
assert.strictEqual(playtime.ok, false)
assert.ok(playtime.errors.some((e) => /rule 1: the title needs some text/.test(e)))
})
test('a typed title made only of markup is refused, even where empty would be allowed (reading 3)', () => {
const bad = titles.validateSettings({ rules: [{ stat: 'kills', topN: 1, text: '[]<>', color: '#ff0000' }] })
assert.strictEqual(bad.ok, false)
assert.ok(bad.errors.some((e) => /the title needs some text/.test(e)))
})
test('a rule’s text wins, then the admin’s category title, then the default (§4.6)', () => {
const rules = [
{ stat: 'kills', topN: 1, text: 'Top Gun', color: '#ff0000' },
{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' },
{ stat: 'headshots', topN: 1, text: '', color: '#0000ff' },
]
const resolved = titles.resolveRules(rules, { animalkills: 'Hunter', kills: 'Ignored' })
assert.deepStrictEqual(resolved.map((r) => r.text), ['Top Gun', 'Hunter', 'Crownbreaker'])
})
test('a playtime rule with no text shows nothing rather than an empty chip', () => {
const resolved = titles.resolveRules([{ stat: 'playtime', topN: 1, text: '', color: '#ffffff' }], {})
assert.deepStrictEqual(resolved, [])
})
test('a category title is checked like a rule’s, and empty is the Reset', () => {
assert.deepStrictEqual(titles.validateCategory('animalkills', ' [Hunter] '), { ok: true, text: 'Hunter' })
assert.deepStrictEqual(titles.validateCategory('animalkills', ''), { ok: true, text: '' })
assert.strictEqual(titles.validateCategory('animalkills', '<>').ok, false)
assert.strictEqual(titles.validateCategory('animalkills', 'x'.repeat(25)).ok, false)
assert.strictEqual(titles.validateCategory('playtime', 'Regular').ok, false, 'playtime has no category title')
assert.strictEqual(titles.validateCategory('deaths', 'X').ok, false)
})
// ── The ingest ────────────────────────────────────────────────────────────
test('a tally’s title counts are added, and its two distances kept as a best', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply(
'main',
item('player.tally', {
steamId: 'p1',
animalKills: 2,
headshots: 1,
bestPvpM: 187.46,
bestPveM: 42.1,
crafted: { attire: 3, weapon: 1, tool: 9 },
built: 12,
missions: 1,
weaponKills: { 'bow_hunting.entity': 2, 'machete.weapon': 1 },
}),
)
const titled = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('animal_kills'))
assert.ok(titled, 'one statement for the title counts')
assert.match(titled.sql, /animal_kills = animal_kills \+ VALUES\(animal_kills\)/)
assert.match(titled.sql, /best_pvp_m = GREATEST\(best_pvp_m, VALUES\(best_pvp_m\)\)/)
assert.match(titled.sql, /best_pve_m = GREATEST\(best_pve_m, VALUES\(best_pve_m\)\)/)
assert.doesNotMatch(titled.sql, /best_pvp_m = best_pvp_m \+/)
const cols = titled.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+), last_seen\)/)[1].split(', ')
const value = (col) => titled.params[3 + cols.indexOf(col)]
assert.strictEqual(value('animal_kills'), 2)
assert.strictEqual(value('crafted_attire'), 3)
assert.strictEqual(value('crafted_weapons'), 1)
assert.strictEqual(value('built'), 12)
assert.strictEqual(value('missions'), 1)
assert.strictEqual(value('best_pvp_m'), 187.5)
assert.strictEqual(value('rockets'), 0)
const weapons = rec.touching('rust_weapon_kills')
assert.deepStrictEqual(
weapons.map((w) => [w.params[3], w.params[4]]),
[['bow_hunting.entity', 2], ['machete.weapon', 1]],
)
assert.match(weapons[0].sql, /kills = kills \+ VALUES\(kills\)/)
})
test('an older plugin’s tally writes no title statement at all', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', item('player.tally', { steamId: 'p1', npcKills: 1 }))
assert.strictEqual(rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('animal_kills')).length, 0)
assert.strictEqual(rec.touching('rust_weapon_kills').length, 0)
})
// ── The standings ─────────────────────────────────────────────────────────
test('a category’s standings read from the right place, in the leaderboard’s order', async () => {
const rec = withRecorder([])
const db = require('../model/titles/titles.db')
const at = { serverId: 'main', wipeId: 'w1', lists, limit: 3 }
await db.standings({ ...at, source: titles.STATS.npckills.source })
const human = rec.statements.at(-1)
assert.match(human.sql, /CAST\(s\.npc_kills AS SIGNED\) - CAST\(s\.animal_kills AS SIGNED\)/)
assert.match(human.sql, /ORDER BY value DESC, MAX\(s\.last_seen\) DESC/)
await db.standings({ ...at, source: titles.STATS.pvpdistance.source })
assert.match(rec.statements.at(-1).sql, /MAX\(s\.best_pvp_m\) AS value/)
await db.standings({ ...at, source: titles.STATS.bowkills.source })
const bow = rec.statements.at(-1)
assert.match(bow.sql, /FROM rust_weapon_kills t/)
assert.deepStrictEqual(bow.params.slice(2, 2 + lists.bow.length), lists.bow)
assert.strictEqual(bow.params.at(-1), 3)
await db.standings({ ...at, source: titles.STATS.ore.source })
assert.match(rec.statements.at(-1).sql, /FROM rust_gather_totals t/)
const before = rec.statements.length
assert.deepStrictEqual(await db.standings({ ...at, source: { weapons: 'nothing' } }), [])
assert.strictEqual(rec.statements.length, before, 'an empty list asks nothing')
})
test('a rename reaches every server’s next answer: the memo is forgotten fleet-wide', async () => {
let override = null
withRecorder((sql) => {
if (sql.includes('FROM rust_title_categories')) return override ? [{ stat: 'animalkills', text: override }] : []
if (sql.includes('FROM rust_title_rules')) return [{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }]
if (sql.includes('title_mode')) return [{ mode: 'first', max: 2 }]
if (sql.includes('SUM(s.animal_kills)')) return [{ steamId: '7', value: '4' }]
return []
})
const model = require('../model/titles/titles.model')
model.forget()
const first = await model.heldFor('main', { wipeId: 'w1', now: 1000 })
assert.deepStrictEqual(first.get('7'), [{ text: 'Beastbane', color: '#00ff00' }])
override = 'Hunter'
await model.setCategory('animalkills', 'Hunter', 1)
const after = await model.heldFor('main', { wipeId: 'w1', now: 1001 })
assert.deepStrictEqual(after.get('7'), [{ text: 'Hunter', color: '#00ff00' }])
})