feat(rust): the kit weekend, linked rewards, reward messages and the kit warning (protocol 14)
PLAN_REDESIGNS §2, the Rust half (D164, D192, D332, D333):
- rust.template.kit_weekend (core registerEventTemplates, MODULE_API 1.13.0):
inputs kit/minutes/message/delivery; one phase advance after the weekend,
core.lease rust.group.permission default/<kit permission> + rust.kit.entitle
linked. Steps only where the kit can use them; validated by the real core.
- rust.kit.entitle: recipients 'linked' (every linked player who has played
on the server, no tally, refused past the bound), optional message ({kit})
and delivery, stored per row (rust_perm_run_grants.notice*).
- perm.sync notices: the untold set to the picked account, keyed
run:step:user, in the digest; the report's noticesTold marks them told.
- rust.options.grouppermissions notes a kit's permission rows via kits.uses
(cached a minute per server; a failure is no note).
- duration: 'minutes' on rust.zone.open and rust.participation.open; the
group-permission lease's value example is true.
- protocol 13 -> 14, coreApi ^1.13.0; ci/core-ref.json pinned to website#212's
head while it is open; engagement-triggers.json regenerated (coreApi line).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -160,6 +160,8 @@ function fakeApi() {
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
// MODULE_API 1.13.0 (PLAN_REDESIGNS.md §2.5).
|
||||
registerEventTemplates(templates) { once('registerEventTemplates'); record.eventTemplates = templates },
|
||||
// Core's `once()` holds here too: a module's commands are one batch.
|
||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
|
||||
299
server/test/stepEditor.test.js
Normal file
299
server/test/stepEditor.test.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// ── 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)
|
||||
})
|
||||
Reference in New Issue
Block a user