W21: the permission push named its fields one by one and left out `notices`, so no kit reward, lend or give ever told its player anything (D333). The body is now built by `pushBody`, which carries every field the payload has; a test fails if a payload field is dropped again. W4: a downed patrol helicopter, Chinook or Bradley is stored with the credit rule in force when it arrived, and the feed reads that stamp. Before, the line re-credited past rows with the server's current rule while the Rotorbane/Tankbreaker counts kept the old one. Rows stored before this change keep today's reading. PLAN_LAST_FIXES.md stage 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
314 lines
16 KiB
JavaScript
314 lines
16 KiB
JavaScript
// ── 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 reconcile = require('../model/permissions/reconcile')
|
|
|
|
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('the push carries the notices, and every other field the payload built (W21)', () => {
|
|
const permSync = require('../permSync')
|
|
const desired = model.buildDesired('main', authored([grantRow({ notice: 'You earned kit vip', noticeDelivery: 'chat' })]))
|
|
const held = reconcile.withHold(desired, new Set())
|
|
const body = permSync.pushBody(desired.hash, held.payload, [], [])
|
|
assert.deepStrictEqual(body.notices, [
|
|
{ key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'chat' },
|
|
])
|
|
// A field the model adds and the push forgets is how W21 happened.
|
|
for (const field of Object.keys(held.payload)) assert.ok(field in body, `the push drops payload.${field}`)
|
|
assert.strictEqual(body.setId, desired.hash)
|
|
})
|
|
|
|
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)
|
|
})
|