Files
Module-Rust/server/test/titleConditions.test.js
wtclaude aea94b5d7a 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
2026-09-28 23:32:59 -05:00

222 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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