Files
Module-Rust/server/test/optionalMods.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

440 lines
20 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 optional mods (PLAN.md §33, protocol 12) ──────────────────────────
//
// BetterChat titles, BetterChat group styles, the voice, and popups. Every test
// here is one of the ways the phase can look right and be wrong:
//
// a title goes to nobody on a fresh wipe; ties follow the leaderboard; a mode keeps what it says
// an operator's title cannot carry markup, and a title made only of markup is refused
// a style is all twelve fields, in the spelling BetterChat's setter takes
// a voice has no sender: no username, and no stray colon where one was
// a style field carries what the site last pushed THERE, and landed only if the game took it
// a withdrawn style is one `chat-group` retirement, never for `default`, and leaves the
// ledger only once BetterChat has removed the group
// titles skip a plugin older than protocol 12, and push again on a restart or a wipe
// a popup refused for want of PopupNotifications is not retried
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore() {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve([])
return Promise.resolve({ affectedRows: 1 })
},
pool: {},
},
}),
)
return queries
}
withCore()
const titles = require('../model/titles/titles')
const chatStyle = require('../model/permissions/chatStyle')
const row = (steamId, value = 0) => ({ steamId, value })
// ── Titles ─────────────────────────────────────────────────────────────────
test('a title rule counts only a stat above zero, so a fresh wipe gives no titles (§33.4 reading 1)', () => {
const held = titles.evaluate(
[{ stat: 'kills', topN: 3, text: 'Killer', color: '#ff0000' }],
{ kills: [row('1', 5), row('2', 0), row('3', 0)] },
)
assert.deepStrictEqual([...held.keys()], ['1'])
const empty = titles.evaluate([{ stat: 'kills', topN: 1, text: 'K', color: '#ff0000' }], { kills: [row('1')] })
assert.strictEqual(empty.size, 0)
})
test('ties follow the leaderboard order: a rule’s top N is the first N rows it was given (reading 2)', () => {
const held = titles.evaluate(
[{ stat: 'playtime', topN: 2, text: 'Regular', color: '#00ff00' }],
{ playtime: [row('9', 60), row('4', 60), row('7', 60)] },
)
assert.deepStrictEqual([...held.keys()], ['9', '4'])
})
test('the mode keeps the first rule met, every rule, or up to N — in the operator’s order (D136)', () => {
const rules = [
{ stat: 'kills', topN: 1, text: 'Killer', color: '#ff0000' },
{ stat: 'npckills', topN: 1, text: 'Hunter', color: '#00ff00' },
{ stat: 'playtime', topN: 1, text: 'Regular', color: '#0000ff' },
]
const standings = {
kills: [row('1', 3)],
npckills: [row('1', 2)],
playtime: [row('1', 9)],
}
const texts = (mode, max) => titles.evaluate(rules, standings, { mode, max }).get('1').map((t) => t.text)
assert.deepStrictEqual(texts('first'), ['Killer'])
assert.deepStrictEqual(texts('all'), ['Killer', 'Hunter', 'Regular'])
assert.deepStrictEqual(texts('upto', 2), ['Killer', 'Hunter'])
assert.deepStrictEqual(texts('nonsense'), ['Killer'], 'an unknown mode shows the fewest')
})
test('a title set is BetterChat markup, sorted, and digests the same however it was built', () => {
const held = new Map([
['2', [{ text: 'B', color: '#00ff00' }]],
['1', [{ text: 'A', color: '#ff0000' }, { text: 'C', color: '#0000ff' }]],
])
const set = titles.wireSet(held)
assert.deepStrictEqual(set, [
{ steamId: '1', text: '[#ff0000]A[/#] [#0000ff]C[/#]' },
{ steamId: '2', text: '[#00ff00]B[/#]' },
])
const again = titles.wireSet(new Map([...held.entries()].reverse()))
assert.strictEqual(titles.digest(set), titles.digest(again))
assert.notStrictEqual(titles.digest(set), titles.digest([]))
})
test('an operator’s title cannot carry markup, and one made only of markup is refused (reading 3)', () => {
assert.strictEqual(titles.cleanText(' [#ff0000]<b>Top</b> {Message} '), '#ff0000bTop/b Message')
const ok = titles.validateSettings({ mode: 'upto', max: 3, rules: [{ stat: 'kills', topN: 1, text: '[Top Killer]', color: '#FF8800' }] })
assert.strictEqual(ok.ok, true)
assert.deepStrictEqual(ok.value.rules[0], { stat: 'kills', topN: 1, text: 'Top Killer', color: '#ff8800' })
const bad = titles.validateSettings({
mode: 'most',
max: 9,
rules: [
{ stat: 'deaths', topN: 0, text: '[]<>', color: 'red' },
{ stat: 'kills', topN: 1, text: 'x'.repeat(25), color: '#ffffff' },
],
})
assert.strictEqual(bad.ok, false)
assert.ok(bad.errors.some((e) => /mode/.test(e)))
assert.ok(bad.errors.some((e) => /max/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: stat/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: top/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: the title needs some text/.test(e)))
assert.ok(bad.errors.some((e) => /rule 1: colour/.test(e)))
assert.ok(bad.errors.some((e) => /rule 2: a title is at most 24/.test(e)))
const many = titles.validateSettings({ rules: new Array(11).fill({ stat: 'kills', topN: 1, text: 'K', color: '#ffffff' }) })
assert.strictEqual(many.ok, false)
})
// ── Styles and the voice ───────────────────────────────────────────────────
const STYLE = {
Priority: 5,
Title: '[Staff]',
TitleColor: '#FF0000',
TitleSize: '16',
TitleHidden: false,
TitleHiddenIfNotPrimary: 'FALSE',
UsernameColor: '#55aaff',
UsernameSize: 15,
MessageColor: 'white',
MessageSize: '15',
ChatFormat: '{Title} {Username}: {Message}',
ConsoleFormat: '{Title} {Username}: {Message}',
}
test('a style is all twelve fields, each in the spelling BetterChat’s setter takes (D138)', () => {
const checked = chatStyle.validateStyle(STYLE)
assert.strictEqual(checked.ok, true)
assert.strictEqual(checked.fields.Priority, '5')
assert.strictEqual(checked.fields.TitleColor, '#ff0000')
assert.strictEqual(checked.fields.TitleHidden, 'false', 'a boolean is true or false, never True')
assert.strictEqual(checked.fields.TitleHiddenIfNotPrimary, 'false')
assert.strictEqual(checked.fields.UsernameSize, '15')
const partial = chatStyle.validateStyle({ Title: '[x]' })
assert.strictEqual(partial.ok, false)
assert.ok(partial.errors.some((e) => /ChatFormat is missing/.test(e)))
const bad = chatStyle.validateStyle({ ...STYLE, ChatFormat: '{Title}', ConsoleFormat: '{Message} {Message}', TitleColor: 'rgb(1,2,3)', Nope: 1 })
assert.ok(bad.errors.some((e) => /ChatFormat must contain \{Message\} exactly once/.test(e)))
assert.ok(bad.errors.some((e) => /ConsoleFormat must contain/.test(e)))
assert.ok(bad.errors.some((e) => /TitleColor/.test(e)))
assert.ok(bad.errors.some((e) => /Nope is not a BetterChat group field/.test(e)))
assert.strictEqual(chatStyle.defaults('vip').Title, '[vip]')
assert.strictEqual(chatStyle.defaults('default').Title, '[Player]')
})
test('a voice has no sender: no username, and no stray colon where BetterChat’s default puts one (D140)', () => {
const { fields } = chatStyle.validateStyle(STYLE)
assert.strictEqual(chatStyle.voiceFormat(fields), '[#ff0000][+16][Staff][/+][/#] [#white][+15]{message}[/+][/#]')
const hidden = chatStyle.voiceFormat({ ...fields, TitleHidden: 'true', ChatFormat: '<{Time}> {Title} {Username} » {Message}' })
assert.strictEqual(hidden, '<> » [#white][+15]{message}[/+][/#]')
// A title is operator text, and `$&` in a replacement string is a pattern.
const dollars = chatStyle.voiceFormat({ ...fields, Title: '[$&]' })
assert.match(dollars, /\[\$&\]/)
assert.strictEqual(chatStyle.voiceFormat({ ...fields, ChatFormat: '{Title}' }), null)
})
// ── The permission mirror's style half ─────────────────────────────────────
test('the desired set carries a style per field, and its value moves the digest but not the row’s identity', () => {
const model = require('../model/permissions/permissions.model')
const base = {
groups: [{ id: 1, name: 'staff', title: 'Staff', rank: 0, parent: '', allServers: true }],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [],
exceptions: [],
steamIdsByUser: new Map(),
// A style belongs to a group ROW since groups became per server (D189).
groupChat: [
{ groupId: 1, field: 'Title', value: '[Staff]' },
{ groupId: 1, field: 'TitleColor', value: '#ff0000' },
],
}
const a = model.buildDesired('main', base)
assert.deepStrictEqual(a.payload.groups[0].chat, { Title: '[Staff]', TitleColor: '#ff0000' })
assert.deepStrictEqual(
a.rows.filter((r) => r.kind === 'chat-field').map((r) => `${r.object}=${r.value}`),
['Title=[Staff]', 'TitleColor=#ff0000'],
)
const b = model.buildDesired('main', { ...base, groupChat: [{ ...base.groupChat[0] }, { ...base.groupChat[1], value: '#00ff00' }] })
assert.notStrictEqual(a.hash, b.hash, 'a recoloured title must push')
assert.strictEqual(model.retirements(a.rows, b.rows).length, 0, 'a changed value is not a retirement')
const none = model.buildDesired('main', { ...base, groupChat: [] })
assert.strictEqual(none.payload.groups[0].chat, undefined)
})
test('each style field carries what the site last pushed to THAT server, or null (§33.2)', () => {
const permSync = require('../permSync')
const groups = [{ name: 'staff', chat: { Title: '[Staff]', TitleColor: '#ff0000' } }, { name: 'vip' }]
const pushed = [
{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Old]' },
{ kind: 'grant', subject: '1', object: 'x', value: null },
]
assert.deepStrictEqual(permSync.withExpect(groups, pushed), [
{ name: 'staff', chat: { Title: { value: '[Staff]', expect: '[Old]' }, TitleColor: { value: '#ff0000', expect: null } } },
{ name: 'vip' },
])
})
test('a withdrawn style is one chat-group retirement per group, and never for default (D139)', () => {
const permSync = require('../permSync')
const retired = [
{ kind: 'chat-field', subject: 'staff', object: 'Title' },
{ kind: 'chat-field', subject: 'staff', object: 'TitleColor' },
{ kind: 'chat-field', subject: 'default', object: 'Title' },
{ kind: 'grant', subject: '1', object: 'kits.vip' },
]
const { styleRetired, sent } = permSync.styleRetirements(retired)
assert.strictEqual(styleRetired.length, 3)
assert.deepStrictEqual(sent, [{ kind: 'chat-group', subject: 'staff', object: '' }])
})
test('a style field landed only when BetterChat took it; drift keeps the game’s value; a removal clears the ledger', async () => {
const queries = withCore()
const permSync = require('../permSync')
require('../sidecarClient').permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
const desired = {
hash: 'h',
rows: [
{ kind: 'group', subject: 'staff', object: '' },
{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Staff]' },
{ kind: 'chat-field', subject: 'staff', object: 'TitleColor', value: '#ff0000' },
{ kind: 'chat-field', subject: 'staff', object: 'MessageSize', value: '15' },
],
}
const report = {
kind: 'perm.report',
foreign: [],
chat: {
loaded: true,
applied: 1,
saved: 1,
drift: [{ group: 'staff', field: 'TitleColor', game: '#123456' }],
failed: [{ group: 'staff', field: 'MessageSize', reason: 'InvalidValue' }],
removed: ['old'],
},
}
const styleRetired = [
{ kind: 'chat-field', subject: 'old', object: 'Title' },
{ kind: 'chat-field', subject: 'gone', object: 'Title' },
]
await permSync.applyReport({ id: 'main' }, { desired, retire: [{ kind: 'chat-group', subject: 'old', object: '' }], styleRetired, report })
const insert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed'))
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('Title [Staff]'), 'the field BetterChat took is pushed, with its value')
assert.ok(!recorded.includes('TitleColor'), 'a hand-edited field is not ours')
assert.ok(!recorded.includes('MessageSize'), 'a field BetterChat refused did not land')
const deletes = queries.filter((q) => q.sql.startsWith('DELETE FROM rust_perm_pushed')).map((q) => q.params.join(' '))
assert.ok(deletes.includes('main chat-field old Title'), 'a removed group leaves the ledger')
assert.ok(!deletes.some((d) => d.includes('gone')), 'a group BetterChat has not removed stays, to be retired again')
assert.ok(!deletes.some((d) => d.includes('chat-group')), 'a chat-group retirement is not a ledger row')
// Protocol 13: every "needs a person" row says which way it went.
const drift = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_drift'))
assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456', 'changed'])
})
test('with BetterChat absent no style field landed, and a withdrawn style is kept for later', async () => {
const queries = withCore()
const permSync = require('../permSync')
require('../sidecarClient').permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
await permSync.applyReport(
{ id: 'main' },
{
desired: { hash: 'h', rows: [{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Staff]' }] },
retire: [],
styleRetired: [{ kind: 'chat-field', subject: 'old', object: 'Title' }],
report: { kind: 'perm.report', foreign: [], chat: { loaded: false } },
},
)
assert.ok(!queries.some((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed')))
assert.ok(!queries.some((q) => q.sql.startsWith('DELETE FROM rust_perm_pushed') && q.params.includes('old')))
})
// ── The title push ─────────────────────────────────────────────────────────
test('titles push on a change, a restart or a wipe, and not on a quiet tick', () => {
const titleSync = require('../titleSync')
const last = { digest: 'd', bootId: 'b1', wipeId: 'w1' }
const state = { bootId: 'b1', wipeId: 'w1' }
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state, last: null }), 'first')
assert.strictEqual(titleSync.reasonToPush({ digest: 'e', state, last }), 'changed')
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state: { ...state, bootId: 'b2' }, last }), 'restart')
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state: { ...state, wipeId: 'w2' }, last }), 'wipe')
assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state, last }), null)
})
test('a plugin older than protocol 12, or a server that is off, is not asked', async () => {
withCore()
const titleSync = require('../titleSync')
const client = require('../sidecarClient')
const calls = []
const saved = client.titles
client.titles = async (...args) => {
calls.push(args)
return { ok: true, data: { kind: 'titles.ok', count: 0, betterChat: false } }
}
try {
assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 1, protocol: 11, wipeId: 'w' }), null)
assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 0, protocol: 12, wipeId: 'w' }), null)
assert.strictEqual(calls.length, 0)
assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 1, protocol: 12, wipeId: 'w', bootId: 'b' }), 'ok')
assert.deepStrictEqual(calls[0][1].titles, [], 'no rules is an empty set, which clears the game')
assert.deepStrictEqual(titleSync.lastPush('main').betterChat, false)
} finally {
client.titles = saved
}
})
// ── Delivery ───────────────────────────────────────────────────────────────
test('a line’s body: a popup carries no format; chat carries the voice; plain chat looks as it did', () => {
const rewards = require('../eventRewards')
const base = { key: 'k', message: 'm' }
assert.deepStrictEqual(rewards.lineBody(base, 'chat', null), base)
assert.deepStrictEqual(rewards.lineBody(base, 'chat', 'F {message}'), { ...base, format: 'F {message}' })
assert.deepStrictEqual(rewards.lineBody(base, 'popup', 'F {message}'), { ...base, delivery: 'popup' })
})
test('rust.announce: a popup where PopupNotifications is missing is refused and not retried (D141, R3)', async () => {
withCore()
const rewards = require('../eventRewards')
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const voice = require('../model/permissions/voice')
const saved = { chat: client.chat, getServer: serversDb.getServer, currentFormat: voice.currentFormat }
const bodies = []
serversDb.getServer = async (id) => ({ id, name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 })
voice.currentFormat = async () => '[#ff0000]S[/#] {message}'
client.chat = async (server, body) => {
bodies.push(body)
return body.delivery === 'popup'
? { ok: true, data: { kind: 'chat.error', reason: 'popup-unavailable', message: 'PopupNotifications is not loaded on this server' } }
: { ok: true, data: { kind: 'chat.ok', said: true } }
}
try {
const announce = rewards.ACTIONS.find((a) => a.id === 'rust.announce')
assert.strictEqual(announce.version, 1, 'a bump would stop every existing step from dispatching')
const refused = await announce.perform({ runId: 1, idempotencyKey: 'k', params: { server: 'main', message: 'hi', delivery: 'popup' } })
assert.strictEqual(refused.ok, false)
assert.strictEqual(refused.retry, false)
assert.match(refused.error, /PopupNotifications/)
assert.strictEqual(bodies[0].format, undefined, 'a popup carries no format')
const said = await announce.perform({ runId: 1, idempotencyKey: 'k2', params: { server: 'main', message: 'hi' } })
assert.strictEqual(said.ok, true)
assert.strictEqual(bodies[1].format, '[#ff0000]S[/#] {message}', 'chat is said in the voice')
const wrong = await announce.perform({ runId: 1, params: { server: 'main', message: 'hi', delivery: 'carrier pigeon' } })
assert.strictEqual(wrong.retry, false)
} finally {
Object.assign(client, { chat: saved.chat })
serversDb.getServer = saved.getServer
voice.currentFormat = saved.currentFormat
}
})
test('the news leg: each server’s own delivery, and one voice for every server that chats (D142)', async () => {
withCore()
const rewards = require('../eventRewards')
const client = require('../sidecarClient')
const servers = require('../model/servers/servers.model')
const voice = require('../model/permissions/voice')
const saved = { chat: client.chat, listForPolling: servers.listForPolling, currentFormat: voice.currentFormat }
const calls = []
servers.listForPolling = async () => [
{ id: 'main', name: 'Main', announceNews: true, newsDelivery: 'chat' },
{ id: 'pve', name: 'PvE', announceNews: true, newsDelivery: 'popup' },
{ id: 'off', name: 'Off', announceNews: false, newsDelivery: 'chat' },
]
voice.currentFormat = async () => 'V {message}'
client.chat = async (server, body) => {
calls.push({ server: server.id, body })
return { ok: true, data: { kind: 'chat.ok', said: true } }
}
try {
const result = await rewards.LEG.dispatch({ id: 3, title: 'Wipe tonight' })
assert.deepStrictEqual(calls, [
{ server: 'main', body: { key: 'news:3', message: 'Wipe tonight', format: 'V {message}' } },
{ server: 'pve', body: { key: 'news:3', message: 'Wipe tonight', delivery: 'popup' } },
])
assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
} finally {
client.chat = saved.chat
servers.listForPolling = saved.listForPolling
voice.currentFormat = saved.currentFormat
}
})
test('the delivery option source offers chat and popup, since core has no enum type', async () => {
const rewards = require('../eventRewards')
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.delivery')
assert.deepStrictEqual((await source.resolve()).map((r) => r.value), ['chat', 'popup'])
})