Files
Module-Rust/server/test/optionalMods.test.js
wtclaude 1b70cef5be feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)
PLAN.md §33, D134-D143. Protocol 12.

- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
  that rank the current wipe, and a mode (first | all | up to N). Worked
  out once in model/titles and read three ways: pushed whole to the game by
  a new titleSync loop (on change, restart or wipe), and on every
  leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
  fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
  pushed ledger, which gains a value column; a field changed in game is a
  `chat-field` drift row with the game's value, adopted into the style or
  put back. A withdrawn style is one `chat-group` retirement, never for
  `default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
  rust.announce chat lines carry its format and the plugin says them with
  no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
  from rust.options.delivery); each server gains news_delivery beside the
  news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
  has loaded. README lists BetterChat and PopupNotifications as optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:22 -05:00

434 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, stats) => ({ steamId, kills: 0, npcKills: 0, playtimeSec: 0, ...stats })
// ── 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', { kills: 5 }), row('2', { kills: 0 }), row('3', { kills: 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', { playtimeSec: 60 }), row('4', { playtimeSec: 60 }), row('7', { playtimeSec: 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', { kills: 3 })],
npckills: [row('1', { npcKills: 2 })],
playtime: [row('1', { playtimeSec: 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: [{ name: 'staff', title: 'Staff', rank: 0, scope: '*' }],
groupPermissions: [],
members: [],
grants: [],
steamIdsByUser: new Map(),
groupChat: [
{ groupName: 'staff', field: 'Title', value: '[Staff]' },
{ groupName: 'staff', 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')
const drift = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_drift'))
assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456'])
})
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'])
})