Stage 2 of PLAN_LAST_FIXES (docs#366). - W7/W11: an "is it loaded" refusal is confirmed live before it is given. New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick would), at most once per server per 5 s and waiting at most 3 s, then reads the stored status again. Used by Economics (step and staff page), Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, TruePVE and RunicNPC. - W3: a world call answered `already-out` (the bridge half comes in stage 3) succeeds as called 0, saying a Travelling Vendor is already out. - W6: "A locked crate dropped by a Chinook is being hacked." - W8: a RaidableBases profile's audit row names RaidableBases, not "@data". - W10: the backpack sizes picker lists each size once, naming its servers. - W13: a RaidableBases name both servers describe differently shows each server's own description. - W14: each RaidableBases note after the first starts its own sentence. - W15: a staff deposit refused at Economics' limit answers 409. - W16: a payment's failedNote explains only the reasons that happened. - W17: the sync state counts player grants that did not land (waitingToLand), and the Permissions page says "in sync · 1 waits for their player to join" instead of plain "in sync". - W18: Guarded Crate's kept note drops the "30 minutes" it could not know. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
335 lines
16 KiB
JavaScript
335 lines
16 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()
|
||
|
||
// The stored status is the whole answer in these tests: nothing asks a
|
||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||
require('../liveStatus').refresh = async () => false
|
||
|
||
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 size above a server’s smallest once, naming every server that offers it (W10)', 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' }, { id: 'off', name: 'Off' }]
|
||
backpacksDb.reports = async () => [
|
||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48, 96] }) },
|
||
{ serverId: 'pve', backpacks: JSON.stringify({ loaded: true, sizes: [12, 24, 48] }) },
|
||
{ serverId: 'off', backpacks: null },
|
||
]
|
||
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes')
|
||
assert.deepStrictEqual(await source.resolve(), [
|
||
{ value: 24, label: '24 slots — PvE' },
|
||
{ value: 48, label: '48 slots — Main, PvE' },
|
||
{ value: 96, label: '96 slots — 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')
|
||
})
|