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