The site half of §11.3.2, on protocol 14:
- `rust.backpack.size` ("Bigger backpack"): a kit reward with a different
permission (D337) — one rust_perm_run_grants row per website user with
backpacks.size.N, no kit, no credit; the perm mirror pushes it and the run's
revert deletes it. risk change, reversible ledger, a new `rust.backpacks`
budget, rust.kit.entitle's recipient modes including `linked`. Refused with
"Backpacks is not installed on this server" in the dry run too (D199), and a
size the server does not offer is refused by name. Its message is never
silent: empty is the take-back warning (D338).
- `rust.options.backpacksizes`: each server's sizes above its smallest, from
the last hello, grouped by server.
- GET /player/rust/backpacks (own links only, 20 reads a minute) and
GET /admin/users/:id/rust/backpacks (that user's links only). Live, never
stored; a stranger's id is a 404 and never reaches the game.
- Client: a shared Backpack panel (pick server and account, open, read once)
on the account page and the admin user page; the grid draws pages of
pageSize, each slot named by the game (D340, no icons), blueprints by what
they make (D341), overflow marked. Backpacks joins the server's mod line.
Server 548/548 (13 new in test/backpacks.test.js), client 79/79 (5 new),
routes.manifest regenerated against the pinned core (89 routes), swagger
fragment regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
326 lines
15 KiB
JavaScript
326 lines
15 KiB
JavaScript
// ── Backpacks (PLAN_REDESIGNS §11.3.2, protocol 14, D337, D338, D340, D341) ─
|
||
//
|
||
// Each test is one way the feature could look right and be wrong:
|
||
//
|
||
// the sizes a step may lease skip the smallest, which everybody holds
|
||
// a server without Backpacks is refused with the reason, in the dry run too (D199)
|
||
// a size the server does not offer is refused by name
|
||
// the lease is a kit reward's row: the size's permission, no kit, no credit (D337)
|
||
// an empty message is the take-back warning, never silence (D338)
|
||
// the revert deletes the step's rows and marks the server for a push
|
||
// a read answers what the game said, live; a stranger's id never reaches it
|
||
// a stack keeps its name, blueprint, skin as text, and one level of contents
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
|
||
const { fakeCtx } = require('./_fakes')
|
||
|
||
function withRecorder(rows = []) {
|
||
const statements = []
|
||
const ctx = fakeCtx({
|
||
db: {
|
||
query: (sql, params = []) => {
|
||
statements.push({ sql, params })
|
||
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
|
||
},
|
||
pool: {},
|
||
},
|
||
})
|
||
require('../core')._reset()
|
||
require('../core').init(ctx)
|
||
return { statements }
|
||
}
|
||
|
||
withRecorder()
|
||
|
||
const client = require('../sidecarClient')
|
||
const serversDb = require('../model/servers/servers.db')
|
||
const servers = require('../model/servers/servers.model')
|
||
const linksDb = require('../model/links/links.db')
|
||
const permDb = require('../model/permissions/permissions.db')
|
||
const backpacksDb = require('../model/backpacks/backpacks.db')
|
||
const backpacks = require('../model/backpacks/backpacks.model')
|
||
const rewards = require('../eventRewards')
|
||
|
||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||
|
||
const A = '76561198000000001'
|
||
const B = '76561198000000002'
|
||
const C = '76561198000000003'
|
||
|
||
const SIZES = [6, 12, 18, 24, 30, 36, 42, 48, 96, 144]
|
||
|
||
// ── What a server reports ──────────────────────────────────────────────────
|
||
|
||
test('a server’s sizes are whole, ascending and unique; the smallest is never leased', () => {
|
||
assert.deepStrictEqual(backpacks.shape(null), { loaded: false, sizes: [] })
|
||
assert.deepStrictEqual(backpacks.shape(JSON.stringify({ loaded: false })), { loaded: false, sizes: [] })
|
||
|
||
const report = backpacks.shape(JSON.stringify({ loaded: true, version: '3.17.8', sizes: [48, 6, 12, 12, 'x', -3, 2.5] }))
|
||
assert.deepStrictEqual(report, { loaded: true, version: '3.17.8', sizes: [6, 12, 48] })
|
||
assert.deepStrictEqual(backpacks.leasable(report), [12, 48])
|
||
assert.deepStrictEqual(backpacks.leasable({ loaded: false, sizes: [] }), [])
|
||
})
|
||
|
||
// ── The step ───────────────────────────────────────────────────────────────
|
||
|
||
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
|
||
const lend = rewards.ACTIONS.find((a) => a.id === 'rust.backpack.size')
|
||
|
||
function stub(t, { loaded = true, sizes = SIZES, people = [], links = {}, existing = [] } = {}) {
|
||
const calls = { inserted: [], dirty: [], deleted: [] }
|
||
const saved = {
|
||
getServer: serversDb.getServer,
|
||
reportFor: backpacksDb.reportFor,
|
||
tallySnapshot: client.tallySnapshot,
|
||
userIdsForSteamIds: linksDb.userIdsForSteamIds,
|
||
listRunGrantsForStep: permDb.listRunGrantsForStep,
|
||
insertRunGrants: permDb.insertRunGrants,
|
||
markDirty: permDb.markDirty,
|
||
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
|
||
}
|
||
|
||
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
|
||
backpacksDb.reportFor = async () => ({ serverId: 'main', backpacks: loaded ? JSON.stringify({ loaded: true, version: '3.17.8', sizes }) : null })
|
||
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
|
||
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
|
||
permDb.listRunGrantsForStep = async () => existing
|
||
permDb.insertRunGrants = async (rows) => {
|
||
calls.inserted.push(...rows)
|
||
return rows.length
|
||
}
|
||
permDb.markDirty = async (serverId) => calls.dirty.push(serverId)
|
||
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
|
||
calls.deleted.push(`${runId}:${stepId}`)
|
||
return ['main']
|
||
}
|
||
|
||
t.after(() => Object.assign(serversDb, { getServer: saved.getServer }))
|
||
t.after(() => Object.assign(backpacksDb, { reportFor: saved.reportFor }))
|
||
t.after(() => Object.assign(client, { tallySnapshot: saved.tallySnapshot }))
|
||
t.after(() => Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds }))
|
||
t.after(() =>
|
||
Object.assign(permDb, {
|
||
listRunGrantsForStep: saved.listRunGrantsForStep,
|
||
insertRunGrants: saved.insertRunGrants,
|
||
markDirty: saved.markDirty,
|
||
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
|
||
}),
|
||
)
|
||
return calls
|
||
}
|
||
|
||
const PARAMS = { server: 'main', size: 48, recipients: 'everyone' }
|
||
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
|
||
|
||
test('a bigger backpack is a ledgered change, priced per recipient on its own budget', () => {
|
||
assert.strictEqual(lend.risk, 'change')
|
||
assert.strictEqual(lend.reversible, 'ledger')
|
||
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3 }), { 'rust.backpacks': 3 })
|
||
assert.deepStrictEqual(lend.cost({ recipients: 'linked' }), { 'rust.backpacks': rewards.MAX_RECIPIENTS })
|
||
})
|
||
|
||
test('a server without Backpacks is refused in the dry run, with the reason (D199)', async (t) => {
|
||
stub(t, { loaded: false })
|
||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
|
||
assert.strictEqual(result.ok, false)
|
||
assert.strictEqual(result.retry, false)
|
||
assert.match(result.error, /Backpacks is not installed on this server \(Main\)/)
|
||
})
|
||
|
||
test('a size the server does not offer, or its smallest, is refused by name', async (t) => {
|
||
stub(t, { sizes: [6, 12, 48] })
|
||
for (const size of [6, 96, 0, 'big']) {
|
||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, size }, verify: true })
|
||
assert.strictEqual(result.ok, false, String(size))
|
||
assert.strictEqual(result.retry, false)
|
||
}
|
||
const named = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, size: 96 }, verify: true })
|
||
assert.match(named.error, /offers 12, 48 slots/)
|
||
|
||
const ok48 = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: PARAMS, verify: true })
|
||
assert.deepStrictEqual(ok48, { ok: true })
|
||
})
|
||
|
||
test('the lease is one row per website user: the size’s permission, no kit, no credit (D337)', async (t) => {
|
||
const calls = stub(t, {
|
||
// A and B are one person (user 1); C has linked nothing.
|
||
people: [person(A, 9), person(B, 5), person(C, 3)],
|
||
links: { [A]: 1, [B]: 1 },
|
||
})
|
||
|
||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||
|
||
assert.strictEqual(result.ok, true)
|
||
assert.strictEqual(calls.inserted.length, 1)
|
||
const [row] = calls.inserted
|
||
assert.strictEqual(row.permission, 'backpacks.size.48')
|
||
assert.strictEqual(row.kit, '')
|
||
assert.strictEqual(row.credit, false)
|
||
assert.strictEqual(row.steamId, A)
|
||
assert.strictEqual(row.userId, 1)
|
||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||
assert.deepStrictEqual(result.resources, [{ kind: 'backpack-size', ref: 'main:7:4', payload: { serverId: 'main', permission: 'backpacks.size.48' } }])
|
||
assert.strictEqual(result.detail.lent, 1)
|
||
assert.deepStrictEqual(result.detail.missed, ['p3'])
|
||
})
|
||
|
||
test('an empty message is the take-back warning, with the size in it (D338)', async (t) => {
|
||
const calls = stub(t, { people: [person(A, 1)], links: { [A]: 1 } })
|
||
await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' }, verify: false })
|
||
assert.match(calls.inserted[0].notice, /^Your backpack holds 48 until the event ends\./)
|
||
assert.strictEqual(calls.inserted[0].noticeDelivery, 'chat')
|
||
|
||
assert.strictEqual(rewards.backpackNotice({}, 48).text, rewards.BACKPACK_NOTICE)
|
||
assert.strictEqual(rewards.backpackNotice({ message: 'Big bag: {size}', delivery: 'popup' }, 96).delivery, 'popup')
|
||
assert.strictEqual(rewards.backpackNotice({ message: 'x', delivery: 'carrier pigeon' }, 96).ok, false)
|
||
assert.strictEqual(rewards.backpackNotice({ message: `${'x'.repeat(254)}{size}` }, 144).ok, false)
|
||
})
|
||
|
||
test('a repeated key finds its rows and lends nothing new', async (t) => {
|
||
const calls = stub(t, { existing: [{ userId: 1 }, { userId: 2 }], people: [person(A, 1)], links: { [A]: 1 } })
|
||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||
assert.strictEqual(result.detail.repeat, true)
|
||
assert.strictEqual(result.detail.lent, 2)
|
||
assert.strictEqual(calls.inserted.length, 0)
|
||
})
|
||
|
||
test('the revert deletes the step’s rows, and the next sync takes the size back', async (t) => {
|
||
const calls = stub(t)
|
||
const result = await lend.revert({ runId: 7, resources: [{ kind: 'backpack-size', ref: 'main:7:4' }], idempotencyKey: 'k-1' })
|
||
assert.deepStrictEqual(result, { ok: true })
|
||
assert.deepStrictEqual(calls.deleted, ['7:4'])
|
||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||
})
|
||
|
||
test('the size list offers each server’s sizes above its smallest, grouped by server', async (t) => {
|
||
const saved = { listForPolling: servers.listForPolling, reports: backpacksDb.reports }
|
||
t.after(() => Object.assign(servers, { listForPolling: saved.listForPolling }) && Object.assign(backpacksDb, { reports: saved.reports }))
|
||
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }]
|
||
backpacksDb.reports = async () => [
|
||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
|
||
{ serverId: 'pve', backpacks: null },
|
||
]
|
||
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes')
|
||
assert.deepStrictEqual(await source.resolve(), [{ value: 48, label: '48 slots', group: 'Main' }])
|
||
})
|
||
|
||
// ── Reading one ────────────────────────────────────────────────────────────
|
||
|
||
function stubRead(t, answer) {
|
||
const sent = []
|
||
const saved = { getForCalling: servers.getForCalling, backpacksRead: client.backpacksRead }
|
||
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(client, { backpacksRead: saved.backpacksRead }))
|
||
servers.getForCalling = async (id) => (id === 'main' ? { id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' } : null)
|
||
client.backpacksRead = async (server, steamId) => {
|
||
sent.push(steamId)
|
||
return typeof answer === 'function' ? answer(steamId) : answer
|
||
}
|
||
return sent
|
||
}
|
||
|
||
test('a backpack is read live and reduced to what the grid draws', async (t) => {
|
||
const sent = stubRead(
|
||
t,
|
||
ok({
|
||
kind: 'backpacks.contents',
|
||
steamId: A,
|
||
exists: true,
|
||
capacity: 12,
|
||
pageSize: 48,
|
||
items: [
|
||
{ slot: 0, shortname: 'wood', display: 'Wood', amount: 1000 },
|
||
{ slot: 2, shortname: 'rifle.ak', display: 'Assault Rifle', amount: 1, contents: [{ slot: 0, shortname: 'weapon.mod.lasersight', display: 'Weapon Lasersight', amount: 1, contents: [{ slot: 0 }] }] },
|
||
{ slot: 3, shortname: 'hoodie', display: 'Hoodie', amount: 1, skin: 10129 },
|
||
{ slot: 5, shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak', amount: 1 },
|
||
{ slot: 14, shortname: 'note', display: 'Note', amount: 1, name: 'spike-a', overflow: true },
|
||
{ slot: 15, shortname: null, itemId: 999, amount: 1, overflow: true },
|
||
],
|
||
}),
|
||
)
|
||
|
||
const result = await backpacks.read({ serverId: 'main', steamId: A })
|
||
assert.deepStrictEqual(sent, [A])
|
||
assert.strictEqual(result.ok, true)
|
||
const bp = result.backpack
|
||
assert.strictEqual(bp.server, 'Main')
|
||
assert.strictEqual(bp.capacity, 12)
|
||
assert.strictEqual(bp.pageSize, 48)
|
||
assert.strictEqual(bp.items.length, 6)
|
||
assert.strictEqual(bp.items[1].contents.length, 1)
|
||
assert.strictEqual(bp.items[1].contents[0].contents, undefined, 'one level of contents, no deeper')
|
||
assert.strictEqual(bp.items[2].skin, '10129', 'a skin id is text')
|
||
assert.strictEqual(bp.items[3].blueprint, 'rifle.ak')
|
||
assert.strictEqual(bp.items[4].name, 'spike-a')
|
||
assert.strictEqual(bp.items[4].overflow, true)
|
||
assert.strictEqual(bp.items[5].itemId, 999)
|
||
})
|
||
|
||
test('a player who never had a backpack is exists: false, with no capacity', async (t) => {
|
||
stubRead(t, ok({ kind: 'backpacks.contents', steamId: A, exists: false }))
|
||
const result = await backpacks.read({ serverId: 'main', steamId: A })
|
||
assert.deepStrictEqual(result.backpack, { serverId: 'main', server: 'Main', steamId: A, exists: false })
|
||
})
|
||
|
||
test('a read says why it has nothing: no server, no Backpacks, or no answer', async (t) => {
|
||
stubRead(t, ok({ kind: 'backpacks.error', reason: 'backpacks-missing', message: 'Backpacks is not installed on this server' }))
|
||
const missing = await backpacks.read({ serverId: 'main', steamId: A })
|
||
assert.strictEqual(missing.status, 409)
|
||
assert.strictEqual(missing.message, 'Backpacks is not installed on this server (Main)')
|
||
|
||
assert.strictEqual((await backpacks.read({ serverId: 'nope', steamId: A })).status, 404)
|
||
|
||
client.backpacksRead = async () => ({ ok: false, status: 'http-503' })
|
||
const down = await backpacks.read({ serverId: 'main', steamId: A })
|
||
assert.strictEqual(down.status, 503)
|
||
assert.match(down.message, /Main has no game connected/)
|
||
})
|
||
|
||
// ── The routes ─────────────────────────────────────────────────────────────
|
||
|
||
function res() {
|
||
return {
|
||
statusCode: 200,
|
||
body: null,
|
||
status(code) {
|
||
this.statusCode = code
|
||
return this
|
||
},
|
||
json(body) {
|
||
this.body = body
|
||
return this
|
||
},
|
||
}
|
||
}
|
||
|
||
test('a backpack is read only for an account its user has linked', async (t) => {
|
||
const links = require('../model/links/links.model')
|
||
const saved = { listForUser: links.listForUser }
|
||
t.after(() => Object.assign(links, saved))
|
||
links.listForUser = async (userId) => (userId === 1 ? [{ steamId: A, name: 'pa' }] : [])
|
||
const sent = stubRead(t, ok({ kind: 'backpacks.contents', steamId: A, exists: false }))
|
||
|
||
const player = require('../router/player/rust.controller')
|
||
const staff = require('../router/admin/usersRust.controller')
|
||
|
||
const own = res()
|
||
await player.ownBackpack({ user: { id: 1 }, query: { serverId: 'main', steamId: A } }, own)
|
||
assert.strictEqual(own.statusCode, 200)
|
||
assert.strictEqual(own.body.exists, false)
|
||
|
||
const stranger = res()
|
||
await player.ownBackpack({ user: { id: 1 }, query: { serverId: 'main', steamId: B } }, stranger)
|
||
assert.strictEqual(stranger.statusCode, 404)
|
||
|
||
const wrongUser = res()
|
||
await staff.readBackpack({ params: { id: '2' }, query: { serverId: 'main', steamId: A } }, wrongUser)
|
||
assert.strictEqual(wrongUser.statusCode, 404)
|
||
|
||
const viaStaff = res()
|
||
await staff.readBackpack({ params: { id: '1' }, query: { serverId: 'main', steamId: A } }, viaStaff)
|
||
assert.strictEqual(viaStaff.statusCode, 200)
|
||
|
||
assert.deepStrictEqual(sent, [A, A], 'a stranger’s id never reaches the game')
|
||
})
|