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

@@ -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' }])
})