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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user