feat(rust): Backpacks — staff and player views, the Bigger backpack step (PLAN_REDESIGNS §11.3)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / frozen-manifest (pull_request) Successful in 1m0s
PR Checks / client-build (pull_request) Successful in 7m23s

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
This commit is contained in:
2026-10-12 14:35:55 -05:00
parent 69ec0f6c46
commit e4591af906
22 changed files with 1786 additions and 7 deletions

View File

@@ -10,6 +10,9 @@
// rust.announce one line in a server's chat, or every server's
// rust.coins.pay Economics coins for the people a mode picks,
// kept once paid (protocol 14, D335)
// rust.backpack.size a bigger Backpacks backpack until the run ends:
// a kit reward with `backpacks.size.N` as its
// permission and no kit (protocol 14, D337)
//
// …and the announce leg, `rust.chat`, which says a published news post in the
// chat of every server whose switch is on (D104).
@@ -43,6 +46,7 @@ const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const emit = require('./engagement/emit')
const economics = require('./model/economics/economics.model')
const backpacks = require('./model/backpacks/backpacks.model')
const voice = require('./model/permissions/voice')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
@@ -112,6 +116,13 @@ const BUDGETS = [
description:
'Economics coins an event pays: the amount times the people paid. A mode that is not a count is priced at the most it could pay. Coins paid are kept — nothing takes them back.',
},
{
id: 'rust.backpacks',
label: 'Backpack sizes',
unit: 'backpacks',
description:
'Bigger backpacks an event lends until it ends: one per recipient. A mode that is not a count is priced at the most it could lend.',
},
]
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
@@ -904,6 +915,208 @@ const coinsPay = {
},
}
// ── A bigger backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2, D337, D338) ──
/** Said when the author writes nothing: the take-back surprises a player nobody warned (D338). */
const BACKPACK_NOTICE =
'Your backpack holds {size} until the event ends. Anything past your normal size then moves to your inventory, or drops at your feet if it is full.'
/**
* A bigger backpack's message, checked like a kit reward's (D333): `{ ok, text,
* delivery }`. Unlike a kit reward's it is never silent: an empty message is
* the default warning above. Measured with the size in place of `{size}`.
*/
function backpackNotice(params, size) {
const written = params.message === undefined || params.message === null ? '' : String(params.message).trim()
const text = written || BACKPACK_NOTICE
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a bigger backpack's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = text.replace(/\{size\}/g, String(size))
if (said.length > MAX_CHAT) {
return { ok: false, error: `a bigger backpack's message is at most ${MAX_CHAT} characters with the size in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
const backpackSize = {
id: 'rust.backpack.size',
label: 'Bigger backpack',
description:
'Lends the people a mode picks from this run\'s tally — or every linked player of the server — a bigger Backpacks backpack until the run ends, and tells each of them in game. Waits for them if they are offline. Someone who already holds a size at least as big gains nothing and loses nothing. At the end, whatever is past their normal size moves to their inventory the next time they open the backpack, or drops at their feet if the inventory is full.',
// A kit reward with a different permission (D337): the same lease, the same
// revert, so the same risk.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server whose backpacks grow. It needs Backpacks installed.' },
{ name: 'size', type: 'int', required: true, example: 48, source: 'rust.options.backpacksizes',
description: 'How many slots, one of the sizes the server\'s Backpacks offers (its backpacks.size.N permissions) above the smallest.' },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'message', type: 'string', required: false, example: BACKPACK_NOTICE,
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {size} is the size. Left empty, it is the example, which warns about the end.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
],
// Priced as a kit reward is: the count for a count, the recipient bound for
// every other mode.
cost: (p) => {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
return { 'rust.backpacks': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
},
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const size = Number(params.size)
if (!Number.isInteger(size) || size < 1) return { ok: false, retry: false, error: `"${params.size}" is not a backpack size — pick one from the list` }
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = backpackNotice(params, size)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(String(params.server || '').trim())
if (!found.ok) return found
const server = found.server
const name = server.name || server.id
// D199: refused with the reason where Backpacks is absent, in the dry run
// too, and a size the server does not offer is refused by name.
const report = await backpacks.forServer(server.id)
if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` }
const offered = backpacks.leasable(report)
if (!offered.includes(size)) {
return {
ok: false,
retry: false,
error: offered.length
? `${name}'s Backpacks offers ${offered.join(', ')} slots above its smallest size, not ${size}`
: `${name}'s Backpacks offers no size above its smallest, so a bigger backpack gives nobody anything — add sizes to its "Permission sizes"`,
}
}
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} lends at most ${MAX_RECIPIENTS} backpacks in one step, not ${count.value}` }
}
if (verify) return { ok: true }
const permission = `backpacks.size.${size}`
const ref = entitlementRef(server.id, runId, stepId)
const resource = { kind: 'backpack-size', ref, payload: { serverId: server.id, permission } }
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
const existing = await permDb.listRunGrantsForStep(runId, stepId)
if (existing.length) {
return {
ok: true,
resources: [resource],
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
}
}
let picked
let bound = MAX_RECIPIENTS
let users
if (mode === 'linked') {
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${name} has more than ${bound} linked players, and lends at most ${bound} backpacks in one step — lend from a tally instead`,
}
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
bound = Number(tally.data.maxRecipients) || bound
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${picked.length} people qualify, and ${name} lends at most ${bound} backpacks in one step — pick a count-based mode or a higher bar`,
}
}
users = await usersFor(picked.map((p) => p.steamId))
}
// One lease per website user (D103's rule). The permission reaches every
// account the user has linked, as a kit reward's does (D28).
const rows = []
const byUser = new Set()
const missed = []
for (const p of picked) {
const userId = users.get(p.steamId)
if (!userId) {
missed.push(p.name)
continue
}
if (byUser.has(userId)) continue
byUser.add(userId)
rows.push({
runId,
stepId,
idemKey: idempotencyKey,
userId,
serverId: server.id,
steamId: p.steamId,
permission,
kit: '',
credit: false,
notice: notice.text.replace(/\{size\}/g, String(size)),
noticeDelivery: notice.delivery,
})
}
await permDb.insertRunGrants(rows)
await permDb.markDirty(server.id)
log.info('backpacks lent', { server: server.id, run: runId, step: stepId, size, mode, lent: rows.length, missed: missed.length })
return {
ok: true,
resources: [resource],
detail: {
server: name,
size,
permission,
mode,
...(count.value === null ? {} : { count: count.value }),
lent: rows.length,
...(rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
},
}
},
// The lease ends as a kit reward's does: the step's rows go and the next sync
// revokes the permission. Backpacks moves the overflow at the owner's next
// open (D338).
revert: (ctx) => kitEntitle.revert(ctx),
reconcile: (ctx) => kitEntitle.reconcile(ctx),
}
/**
* The body of a chat line: its delivery, and the voice's format when the line
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
@@ -1141,6 +1354,21 @@ const OPTION_SOURCES = [
{ value: 'chat', label: 'Chat' },
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
]),
{
// From each server's last hello: the sizes its Backpacks offers above the
// smallest, grouped by server (D337). The step checks the pair it is given.
id: 'rust.options.backpacksizes',
label: 'Backpack sizes',
description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.",
async resolve() {
const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()])
const rows = []
for (const s of list) {
for (const n of backpacks.leasable(reports.get(s.id))) rows.push({ value: n, label: `${n} slots`, group: s.name || s.id })
}
return rows
},
},
{
id: 'rust.options.chatservers',
label: 'Chat servers',
@@ -1152,7 +1380,7 @@ const OPTION_SOURCES = [
},
]
const ACTIONS = [participationOpen, participationCollect, kitEntitle, coinsPay, announce]
const ACTIONS = [participationOpen, participationCollect, kitEntitle, coinsPay, backpackSize, announce]
module.exports = {
MAX_RECIPIENTS,
@@ -1171,6 +1399,8 @@ module.exports = {
rewardNotice,
coinsNotice,
paymentKey,
backpackNotice,
BACKPACK_NOTICE,
chatLine,
chatKey,
lineBody,