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

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