feat(rust): Backpacks — staff and player views, the Bigger backpack step (PLAN_REDESIGNS §11.3)
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:
153
client/src/components/Backpack.jsx
Normal file
153
client/src/components/Backpack.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user