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:
221
server/test/titleConditions.test.js
Normal file
221
server/test/titleConditions.test.js
Normal 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' }])
|
||||
})
|
||||
Reference in New Issue
Block a user