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
434 lines
20 KiB
JavaScript
434 lines
20 KiB
JavaScript
// ── 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'])
|
||
})
|