// ── The step editor's Rust half (PLAN_REDESIGNS.md §2, protocol 14) ────────── // // `linked` rewards every linked player of the server, reads no tally, and is // refused past the server's bound rather than trimmed (§2.5) // a reward's message is checked with the kit's name in it, and a blank one // says nothing — every step saved before protocol 14 (D333) // the sync carries every untold message to the account the reward picked, // and stops once it is told; the key reads back to its row // the kit warning names how many linked players have used the kit up (§2.6) // the kit weekend builds the plan the plan describes, and only the steps the // kit can use (§2.5, D164) const test = require('node:test') const assert = require('node:assert') const { fakeCtx } = require('./_fakes') require('../core')._reset() require('../core').init(fakeCtx()) const client = require('../sidecarClient') const serversDb = require('../model/servers/servers.db') const servers = require('../model/servers/servers.model') const permDb = require('../model/permissions/permissions.db') const linksDb = require('../model/links/links.db') const emit = require('../engagement/emit') const rewards = require('../eventRewards') const leases = require('../eventLeases') const templates = require('../eventTemplates') const model = require('../model/permissions/permissions.model') const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle') const ok = (data) => ({ ok: true, status: 'ok', data }) const MAIN = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 } const KIT = { name: 'vip', permission: 'kits.vip', max: 2, cooldown: 86400 } /** Swap one module's members for a test, and put them back after. */ function swap(t, mod, members) { const saved = {} for (const k of Object.keys(members)) saved[k] = mod[k] Object.assign(mod, members) t.after(() => Object.assign(mod, saved)) } function rewardStubs(t, { linked = [], kits = [KIT], maxRecipients = 100 } = {}) { const calls = { grants: [], tally: 0 } swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) }) swap(t, client, { kits: async () => ok({ kind: 'kits.list', kits, maxRecipients }), tallySnapshot: async () => { calls.tally++ return ok({ kind: 'tally.snapshot', people: [], maxRecipients }) }, }) swap(t, permDb, { insertRunGrants: async (rows) => calls.grants.push(...rows), listRunGrantsForStep: async () => [], markDirty: async () => {}, }) swap(t, linksDb, { linkedOnServer: async (serverId, limit) => linked.slice(0, limit), }) swap(t, emit, { entitled: () => 0 }) return calls } const envelope = (params) => ({ runId: '7', stepId: '3', idempotencyKey: 'k', params }) // ── linked ────────────────────────────────────────────────────────────────── test('linked rewards every linked player of the server and reads no tally', async (t) => { const calls = rewardStubs(t, { linked: [ { steamId: '76561190000000001', userId: 11, name: 'Ann' }, { steamId: '76561190000000002', userId: 12, name: 'Bo' }, ], }) const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' })) assert.strictEqual(answer.ok, true, answer.error) assert.strictEqual(calls.tally, 0) assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.kit, r.credit]), [ [11, '76561190000000001', 'vip', true], [12, '76561190000000002', 'vip', true], ]) // No message asked for, so none written: a step from before protocol 14. assert.ok(calls.grants.every((r) => !('notice' in r))) assert.strictEqual(answer.detail.mode, 'linked') }) test('linked past the server\'s bound is refused, never trimmed', async (t) => { const linked = Array.from({ length: 4 }, (_, i) => ({ steamId: `7656119000000000${i}`, userId: i + 1 })) rewardStubs(t, { linked, maxRecipients: 3 }) const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' })) assert.strictEqual(answer.ok, false) assert.strictEqual(answer.retry, false) assert.match(answer.error, /more than 3 linked players/) }) test('linked takes no count and is priced at the most a step can grant', () => { assert.deepStrictEqual(rewards.checkCount('linked', 5), { ok: true, value: null }) assert.deepStrictEqual(entitle.cost({ recipients: 'linked' }), { 'rust.grants': rewards.MAX_RECIPIENTS }) }) // ── The reward's message (D333) ───────────────────────────────────────────── test('a message is written per row with the kit\'s name in it, and says how it goes', async (t) => { const calls = rewardStubs(t, { linked: [{ steamId: '76561190000000001', userId: 11 }] }) const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked', message: 'You earned kit {kit} — type /kit {kit}', delivery: 'Popup', })) assert.strictEqual(answer.ok, true, answer.error) assert.strictEqual(calls.grants[0].notice, 'You earned kit vip — type /kit vip') assert.strictEqual(calls.grants[0].noticeDelivery, 'popup') assert.match(answer.detail.told, /1 will be told in game, by popup/) }) test('a message is checked on the form: its length with the kit in it, and where it goes', () => { assert.deepStrictEqual(rewards.rewardNotice({}, 'vip'), { ok: true, text: null, delivery: null }) assert.deepStrictEqual(rewards.rewardNotice({ message: ' ' }, 'vip'), { ok: true, text: null, delivery: null }) assert.strictEqual(rewards.rewardNotice({ message: 'hi' }, 'vip').delivery, 'chat') assert.match(rewards.rewardNotice({ message: 'hi', delivery: 'shout' }, 'vip').error, /not to "shout"/) // 250 characters fit; with a long kit name in place of {kit} they do not. const text = `${'x'.repeat(245)}{kit}` assert.strictEqual(rewards.rewardNotice({ message: text }, 'vip').ok, true) assert.match(rewards.rewardNotice({ message: text }, 'a-very-long-kit-name').error, /at most 256 characters/) }) // ── The sync's notices ────────────────────────────────────────────────────── const authored = (runGrants) => ({ groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [], grants: [], steamGrants: [], exceptions: [], groupChat: [], steamIdsByUser: new Map([[11, ['76561190000000001']], [12, ['76561190000000002']]]), runGrants, }) const grantRow = (extra) => ({ runId: '7', stepId: '3', userId: 11, serverId: 'main', steamId: '76561190000000001', permission: 'kits.vip', kit: 'vip', credit: 1, notice: null, noticeDelivery: null, noticeToldAt: null, ...extra, }) test('the sync carries each untold message to the account the reward picked', () => { const desired = model.buildDesired('main', authored([ grantRow({ notice: 'You earned kit vip', noticeDelivery: 'popup' }), // Told already: it leaves the set, which is what lets the plugin forget it. grantRow({ userId: 12, steamId: '76561190000000002', notice: 'x', noticeToldAt: new Date() }), // No message: a reward that says nothing. grantRow({ stepId: '4' }), // Another server's reward is that server's to say. grantRow({ serverId: 'alt', stepId: '5', notice: 'elsewhere' }), // Unlinked since: nobody to tell. grantRow({ stepId: '6', steamId: '76561190000000099', notice: 'gone' }), ])) assert.deepStrictEqual(desired.payload.notices, [ { key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'popup' }, ]) }) test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => { const none = model.buildDesired('main', authored([grantRow({})])) const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })])) const told = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeToldAt: new Date() })])) assert.deepStrictEqual(none.payload.notices, []) assert.notStrictEqual(none.hash, one.hash) assert.strictEqual(none.hash, told.hash) }) test('a told key reads back to its row; a malformed one changes nothing', async (t) => { const core = require('../core') const queries = [] swap(t, core, { query: async (sql, params) => { queries.push(params) return { affectedRows: 1 } }, }) const marked = await permDb.markNoticesTold('main', ['7:3:11', 'nonsense', '7:3:x']) assert.strictEqual(marked, 1) assert.deepStrictEqual(queries, [['main', '7', '3', 11]]) }) // ── The kit warning (§2.6) ────────────────────────────────────────────────── test('the note says the limit, the cooldown, and how many have used the kit up', () => { assert.strictEqual( leases.kitNote(KIT, { asked: 41, exhausted: 9, onCooldown: 3 }), 'Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up, 3 are on its cooldown. A kit weekend\'s extra use is what lets them take it again.', ) assert.match(leases.kitNote({ name: 'free', max: 0, cooldown: 0 }, { asked: 5 }), /^Kit free: no use limit — 5 linked players\./) assert.match(leases.kitNote(KIT, { asked: 0 }), /no linked player has played on this server yet/) }) test('the group-permission source notes the rows that grant a kit\'s permission', async (t) => { leases.kitNoteCache.clear() t.after(() => leases.kitNoteCache.clear()) const asked = [] swap(t, servers, { listForPolling: async () => [{ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }] }) swap(t, client, { permCatalogue: async () => ok({ kind: 'perm.catalogue', permissions: ['kits.vip', 'other.thing'], groups: [{ name: 'default' }] }), kits: async () => ok({ kind: 'kits.list', kits: [KIT, { name: 'open', permission: '', max: 1 }] }), kitsUses: async (server, kit, steamIds) => { asked.push({ kit, steamIds }) return ok({ kind: 'kits.uses', kit, max: 2, cooldown: 86400, asked: steamIds.length, exhausted: 1, onCooldown: 0 }) }, }) swap(t, linksDb, { linkedOnServer: async () => [{ steamId: '76561190000000001' }, { steamId: '76561190000000002' }] }) const source = leases.OPTION_SOURCES.find((s) => s.id === 'rust.options.grouppermissions') const rows = await source.resolve({}) const vip = rows.find((r) => r.value === 'main/default/kits.vip') const other = rows.find((r) => r.value === 'main/default/other.thing') assert.match(vip.note, /^Kit vip: 2 uses, 24 h cooldown — 1 of 2 linked players have used it up\./) assert.strictEqual('note' in other, false) // Only the kit a permission gates is asked about, and with the server's linked players. assert.deepStrictEqual(asked, [{ kit: 'vip', steamIds: ['76561190000000001', '76561190000000002'] }]) // Asked again within the minute: answered from the cache. await source.resolve({}) assert.strictEqual(asked.length, 1) }) test('a kit warning that cannot be read is no note, never a broken field', async (t) => { leases.kitNoteCache.clear() t.after(() => leases.kitNoteCache.clear()) swap(t, client, { kits: async () => ok({ kind: 'kits.list', kits: [KIT] }), kitsUses: async () => ({ ok: false, status: 'timeout', data: null }), }) swap(t, linksDb, { linkedOnServer: async () => [] }) const notes = await leases.kitNotes({ id: 'main' }) assert.strictEqual(notes.size, 0) }) // ── The kit weekend (§2.5) ────────────────────────────────────────────────── const weekend = templates.TEMPLATES.find((x) => x.id === 'rust.template.kit_weekend') function weekendStubs(t, kits = [KIT]) { swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) }) swap(t, client, { kits: async () => ok({ kind: 'kits.list', kits, maxRecipients: 100 }) }) } test('the kit weekend is one phase as long as the weekend, holding both steps', async (t) => { weekendStubs(t) const draft = await weekend.build({ inputs: { kit: 'main/vip', minutes: 2880, message: 'Kit weekend! {kit}', delivery: 'popup' } }) assert.strictEqual(draft.title, 'Kit weekend: vip on Main') assert.strictEqual(draft.summary, 'For 48 hours, everybody on Main may redeem vip, and every linked player gets one more use of it.') const [phase] = draft.spec.phases assert.deepStrictEqual(phase.advance, { after: '2880m' }) assert.deepStrictEqual(phase.steps.map((s) => s.actionId), ['core.lease', 'rust.kit.entitle']) assert.deepStrictEqual(phase.steps[0].params, { lease: 'rust.group.permission', target: 'main/default/kits.vip', value: 'true', minutes: 2880, }) assert.deepStrictEqual(phase.steps[1].params, { kit: 'main/vip', recipients: 'linked', message: 'Kit weekend! {kit}', delivery: 'popup', }) }) test('a kit with no permission is step 2 alone; one with no use limit is step 1 alone', async (t) => { weekendStubs(t, [{ name: 'open', permission: '', max: 1 }, { name: 'free', permission: 'kits.free', max: 0 }]) const open = await weekend.build({ inputs: { kit: 'main/open', minutes: 60 } }) assert.deepStrictEqual(open.spec.phases[0].steps.map((s) => s.actionId), ['rust.kit.entitle']) assert.ok(!('message' in open.spec.phases[0].steps[0].params)) const free = await weekend.build({ inputs: { kit: 'main/free', minutes: 60 } }) assert.deepStrictEqual(free.spec.phases[0].steps.map((s) => s.actionId), ['core.lease']) }) test('the kit weekend refuses what its steps would refuse, in the author\'s words', async (t) => { weekendStubs(t) assert.match((await weekend.build({ inputs: { kit: 'vip', minutes: 60 } })).error, /as server\/kit/) assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 20000 } })).error, /1 to 10080 minutes/) assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 60, delivery: 'shout', message: 'x' } })).error, /not to "shout"/) assert.match((await weekend.build({ inputs: { kit: 'gone/vip', minutes: 60 } })).error, /gone/) assert.match((await weekend.build({ inputs: { kit: 'main/nope', minutes: 60 } })).error, /no kit called "nope"/) }) test('the template\'s inputs are params core will accept, and its minutes are a duration', () => { for (const p of weekend.inputs) { assert.ok(p.example !== undefined && p.example !== '', `${p.name} needs an example`) } assert.strictEqual(weekend.inputs.find((p) => p.name === 'minutes').duration, 'minutes') assert.ok(/^rust\.[a-z0-9_]+\.[a-z0-9_]+$/.test(weekend.id)) }) test('the module\'s timed params say they are durations', () => { const zone = require('../eventWorld').ACTIONS.find((a) => a.id === 'rust.zone.open') const tally = rewards.ACTIONS.find((a) => a.id === 'rust.participation.open') assert.strictEqual(zone.params.find((p) => p.name === 'minutes').duration, 'minutes') assert.strictEqual(tally.params.find((p) => p.name === 'minutes').duration, 'minutes') const groupLease = leases.LEASES.find((l) => l.id === 'rust.group.permission') assert.strictEqual(groupLease.example, true) })