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

@@ -135,6 +135,12 @@ export const playerEconomics = {
list: () => req('/player/rust/economics'),
}
// Your own backpack on one server, from one of your linked accounts
// (PLAN_REDESIGNS §11.3.2): read live from the game, once per open.
export const playerBackpacks = {
read: (serverId, steamId) => req(`/player/rust/backpacks${query({ serverId, steamId })}`),
}
// ── admin ─────────────────────────────────────────────────────────────────
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
// and a save that omits the field leaves the stored credential alone — so an
@@ -337,6 +343,13 @@ export const adminUserEconomics = {
req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`, { method: 'POST', body }),
}
// …and its Backpacks half (§11.3.2): what this person carries, read live. There
// is no write, here or in the game (R2).
export const adminUserBackpacks = {
read: (userId, serverId, steamId) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
}
// ── core's public event calendar ───────────────────────────────────────────
// The one CORE route this module reads, and core published `runId` on it for
// exactly this: a map marker holds a run id and nothing else, and the calendar is
@@ -357,6 +370,7 @@ export default {
playerLinks,
playerPermissions,
playerEconomics,
playerBackpacks,
admin,
adminPermissions,
adminConfig,
@@ -367,6 +381,7 @@ export default {
adminUserLinks,
adminUserPermissions,
adminUserEconomics,
adminUserBackpacks,
coreEvents,
BASE,
}

View File

@@ -0,0 +1,153 @@
// ── A Backpacks backpack, read live (PLAN_REDESIGNS §11.3.2) ──────────────
//
// One component for both readers — staff on a user's admin page, a player on
// their own account page — because the backpack is the same thing to both and
// only the route differs. The caller hands over `read(serverId, steamId)`.
//
// **Opening reads once; nothing polls.** A read runs on the game's main thread
// (§11.3.1), so the panel asks when somebody asks it to and not again until
// they do. Nothing is stored on the site either.
//
// **No icons (D340).** The module has no item art (R9 kept it out of the asset
// bridge), so a slot says the game's own English name, which the plugin sends.
import { useState } from 'react'
import { count, shortId } from '../lib/format.js'
import { backpackPages, overflowCount, stackLabel } from '../lib/backpack.js'
const CELL = 92
/** One slot: its name, amount, and what rides inside it. */
function Cell({ cell }) {
const { stack, overflow } = cell
const border = overflow ? '1px dashed #d89b3c' : '1px solid var(--line-soft)'
const title = stack
? [stackLabel(stack), stack.name ? `“${stack.name}”` : null, stack.skin ? `skin ${stack.skin}` : null, overflow ? 'past the capacity' : null]
.filter(Boolean)
.join(' · ')
: overflow
? 'past the capacity'
: `slot ${cell.slot + 1}`
return (
<div
className="sans"
title={title}
style={{
border,
borderRadius: 4,
minHeight: 58,
padding: '4px 6px',
fontSize: '0.72rem',
lineHeight: 1.25,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
minWidth: 0,
overflow: 'hidden',
background: stack ? 'transparent' : 'var(--line-soft)',
opacity: stack ? 1 : 0.35,
}}
>
{stack && (
<>
<span style={{ color: 'var(--head)', overflowWrap: 'anywhere' }}>{stackLabel(stack)}</span>
{stack.name && <span className="dim" style={{ overflowWrap: 'anywhere' }}>“{stack.name}”</span>}
{(stack.contents || []).map((c) => (
<span key={c.slot} className="dim" style={{ overflowWrap: 'anywhere' }}>+ {stackLabel(c)}{c.amount > 1 ? ` ×${count(c.amount)}` : ''}</span>
))}
<span style={{ display: 'flex', justifyContent: 'space-between', gap: 4 }}>
<span className="dim">{stack.skin ? 'skinned' : ''}</span>
{stack.amount > 1 && <strong>×{count(stack.amount)}</strong>}
</span>
</>
)}
</div>
)
}
/** The grid itself, one block per page. */
export function BackpackGrid({ backpack }) {
if (!backpack.exists) {
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No backpack on {backpack.server} yet — it has never been opened.</p>
}
const pages = backpackPages(backpack)
const waiting = overflowCount(backpack)
const held = (backpack.items || []).length
return (
<div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 8px' }}>
{count(held)} stack{held === 1 ? '' : 's'} in {count(backpack.capacity)} slots on {backpack.server}
{waiting > 0 && (
<span style={{ color: '#d89b3c' }}>
{' '}· {count(waiting)} past the capacity, moved to the owner’s inventory (or dropped at their feet if it is full) the next time they open it
</span>
)}
</p>
{pages.map((page) => (
<div key={page.index} style={{ marginBottom: 10 }}>
{pages.length > 1 && <div className="field-label" style={{ margin: '0 0 6px', fontSize: '0.7rem' }}>Page {page.index + 1}</div>}
<div style={{ display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(${CELL}px, 1fr))`, gap: 4 }}>
{page.cells.map((cell) => (
<Cell key={cell.slot} cell={cell} />
))}
</div>
</div>
))}
</div>
)
}
/**
* Pick a server and an account, open, read once. `servers` is `[{ id, name }]`,
* `accounts` `[{ steamId, name }]`, and `read` the route's binding.
*/
export default function Backpack({ servers, accounts, read }) {
const [serverId, setServerId] = useState(servers.length ? servers[0].id : '')
const [steamId, setSteamId] = useState(accounts.length ? accounts[0].steamId : '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [backpack, setBackpack] = useState(null)
if (!servers.length || !accounts.length) return null
async function open(event) {
event.preventDefault()
if (busy || !serverId || !steamId) return
setBusy(true)
setError('')
try {
setBackpack(await read(serverId, steamId))
} catch (err) {
// "Backpacks is not installed on this server (Main)" is the server's sentence (D199).
setBackpack(null)
setError(err.message || 'That backpack could not be read.')
} finally {
setBusy(false)
}
}
return (
<div>
<form onSubmit={open} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
{servers.length > 1 && (
<select className="input" value={serverId} onChange={(e) => setServerId(e.target.value)} aria-label="Server">
{servers.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
</select>
)}
{accounts.length > 1 && (
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
{accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || shortId(a.steamId)}</option>)}
</select>
)}
<button type="submit" className="btn" disabled={busy}>
{busy ? 'Reading…' : backpack ? 'Read again' : 'Open backpack'}
</button>
</form>
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '0 0 6px' }}>{error}</p>}
{backpack && <BackpackGrid backpack={backpack} />}
</div>
)
}

View File

@@ -0,0 +1,52 @@
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
//
// Pure, like `format.js`, so the suite can ask it without a DOM. The page that
// draws a backpack only maps over what these return.
//
// The read's `slot` runs across pages: page two of a 96-slot backpack starts at
// slot 48 when the page size is 48 (§11.3.1). A stack at or past the capacity
// is overflow — it waits for the owner's next open to be moved to their
// inventory, or the ground (D338) — so the grid draws those slots too, after
// the backpack's own, marked.
/**
* The pages to draw: `[{ index, cells: [{ slot, stack, overflow }] }]`.
* Enough slots for the capacity and for every stack, whichever reaches
* further; a backpack that does not exist is no pages at all.
*/
export function backpackPages(backpack) {
if (!backpack || !backpack.exists) return []
const items = Array.isArray(backpack.items) ? backpack.items : []
const capacity = Math.max(0, Number(backpack.capacity) || 0)
const pageSize = Math.max(1, Number(backpack.pageSize) || 48)
const bySlot = new Map(items.map((s) => [Number(s.slot), s]))
const furthest = items.reduce((max, s) => Math.max(max, Number(s.slot) + 1), 0)
const total = Math.max(capacity, furthest)
const pages = []
for (let start = 0; start < total; start += pageSize) {
const cells = []
for (let slot = start; slot < Math.min(start + pageSize, total); slot++) {
cells.push({ slot, stack: bySlot.get(slot) || null, overflow: slot >= capacity })
}
pages.push({ index: pages.length, cells })
}
return pages
}
/**
* What a slot says: the game's name (D340), "… blueprint" for a blueprint
* (D341), then the shortname, then the bare item id for an item the game no
* longer knows.
*/
export function stackLabel(stack) {
if (!stack) return ''
const name = stack.display || stack.shortname || (stack.itemId !== undefined ? `item ${stack.itemId}` : 'unknown item')
return stack.blueprint ? `${name} blueprint` : name
}
/** How many stacks wait to be moved out at the owner's next open. */
export function overflowCount(backpack) {
return backpack && Array.isArray(backpack.items) ? backpack.items.filter((s) => s.overflow).length : 0
}

View File

@@ -260,7 +260,21 @@ export function integrationsLine(r) {
}
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
return r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
return r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2), from protocol 14. Its sizes are what a
* Bigger backpack step may lease (D337), so they are said.
*/
export function backpacksLine(b) {
if (!b.loaded) return 'Backpacks is not loaded — backpack views and bigger-backpack rewards wait for it.'
const name = `Backpacks ${b.version || ''}`.trim()
const sizes = Array.isArray(b.sizes) ? b.sizes : []
return sizes.length > 1
? `${name} is loaded — sizes ${sizes.join(', ')}; an event may lend any above ${sizes[0]}.`
: `${name} is loaded, but offers no size above its smallest, so an event has nothing bigger to lend.`
}
/**

View File

@@ -18,6 +18,7 @@ import { useCallback, useRef, useState } from 'react'
import { ago, coins, count, duration } from '../../lib/format.js'
import { useAsync } from '../../core.js'
import api from '../../api.js'
import Backpack from '../../components/Backpack.jsx'
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
function SectionTitle({ children }) {
@@ -353,6 +354,27 @@ function EconomicsPanel({ userId }) {
)
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2): what this person carries, per server and
* linked account, read live when staff open it. Read only (R2): staff see the
* overflow too, and only the owner's own open moves it (D338).
*/
function BackpackPanel({ userId, links }) {
const { data } = useAsync(() => api.servers.list(), [])
const list = (data && data.servers) || []
if (!list.length) return null
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div className="field-label" style={{ marginBottom: 8 }}>Backpack</div>
<Backpack
servers={list.map((s) => ({ id: s.id, name: s.name }))}
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
read={(serverId, steamId) => api.adminUserBackpacks.read(userId, serverId, steamId)}
/>
</div>
)
}
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
@@ -405,6 +427,9 @@ export default function UserRustSections({ userId }) {
{/* Coins belong to a Steam account, so only with one linked. */}
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
{/* A backpack belongs to a Steam account too. */}
{data.links.length > 0 && <BackpackPanel userId={userId} links={data.links} />}
</div>
</section>
)

View File

@@ -18,6 +18,7 @@ import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago, coins, shortId } from '../../lib/format.js'
import api from '../../api.js'
import Backpack from '../../components/Backpack.jsx'
/** The code field, and the four answers it can produce. */
function LinkForm({ onLinked }) {
@@ -327,6 +328,24 @@ function Coins() {
)
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2): your own backpack, from one of your
* linked accounts, read live when you open it. A server without Backpacks
* says so when asked (D199).
*/
function OwnBackpack({ links }) {
const { data } = useAsync(() => api.playerServers.list(), [])
const list = (data && data.servers) || []
if (!list.length) return null
return (
<Backpack
servers={list.map((s) => ({ id: s.id, name: s.name }))}
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
read={(serverId, steamId) => api.playerBackpacks.read(serverId, steamId)}
/>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
@@ -398,6 +417,9 @@ export default function Account() {
<div className="field-label" style={{ margin: '30px 0 12px' }}>Coins</div>
{data && links.length > 0 ? <Coins /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your balance.</p>}
<div className="field-label" style={{ margin: '30px 0 12px' }}>Backpack</div>
{data && links.length > 0 ? <OwnBackpack links={links} /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your backpack.</p>}
</div>
)
}

View File

@@ -0,0 +1,62 @@
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
//
// The grid is drawn from these, so each test is a backpack that could be drawn
// wrong: a two-page one, one with overflow past its capacity, one that never
// existed, and the labels a slot can carry (D340, D341).
import test from 'node:test'
import assert from 'node:assert/strict'
import { backpackPages, overflowCount, stackLabel } from '../src/lib/backpack.js'
test('a backpack that does not exist is no pages', () => {
assert.deepEqual(backpackPages(null), [])
assert.deepEqual(backpackPages({ exists: false }), [])
})
test('slots run across pages of the page size', () => {
const pages = backpackPages({
exists: true,
capacity: 96,
pageSize: 48,
items: [{ slot: 0, shortname: 'wood', amount: 1000 }, { slot: 50, shortname: 'note', amount: 1 }],
})
assert.equal(pages.length, 2)
assert.equal(pages[0].cells.length, 48)
assert.equal(pages[1].cells.length, 48)
assert.equal(pages[0].cells[0].stack.shortname, 'wood')
assert.equal(pages[1].cells[2].slot, 50)
assert.equal(pages[1].cells[2].stack.shortname, 'note')
assert.ok(pages.every((p) => p.cells.every((c) => !c.overflow)))
})
test('stacks past the capacity are drawn after it, marked overflow', () => {
// A size revoked at the end of a run: capacity 6, five notes still on what was page two.
const backpack = {
exists: true,
capacity: 6,
pageSize: 48,
items: [{ slot: 0, shortname: 'wood', amount: 1 }, ...[48, 49, 50, 51, 52].map((slot) => ({ slot, shortname: 'note', amount: 1, overflow: true }))],
}
const pages = backpackPages(backpack)
assert.equal(pages.length, 2)
assert.equal(pages[0].cells.slice(0, 6).filter((c) => c.overflow).length, 0)
assert.ok(pages[0].cells.slice(6).every((c) => c.overflow))
assert.equal(pages[1].cells.length, 5)
assert.equal(overflowCount(backpack), 5)
})
test('an empty backpack still draws its capacity', () => {
const pages = backpackPages({ exists: true, capacity: 12, pageSize: 48, items: [] })
assert.equal(pages.length, 1)
assert.equal(pages[0].cells.length, 12)
assert.ok(pages[0].cells.every((c) => c.stack === null))
})
test('a slot is named by the game, a blueprint by what it makes', () => {
assert.equal(stackLabel({ shortname: 'rifle.ak', display: 'Assault Rifle' }), 'Assault Rifle')
assert.equal(stackLabel({ shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak' }), 'Assault Rifle blueprint')
assert.equal(stackLabel({ shortname: 'wood' }), 'wood')
assert.equal(stackLabel({ shortname: null, itemId: 999 }), 'item 999')
assert.equal(stackLabel(null), '')
})