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:
36
server/model/backpacks/backpacks.db.js
Normal file
36
server/model/backpacks/backpacks.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── SQL for Backpacks (PLAN_REDESIGNS §11.3.2) ────────────────────────────
|
||||
//
|
||||
// What each server's last hello said about Backpacks. The backpacks themselves
|
||||
// are never stored (§11.3.2: live, never stored); this file only reads the
|
||||
// report the plugin makes about the plugin.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/**
|
||||
* One server's report: `{ serverId, backpacks }`, with `backpacks` as the
|
||||
* driver hands JSON_EXTRACT back — text, or null for a server whose plugin
|
||||
* never said (no state yet, or a plugin before protocol 14).
|
||||
*/
|
||||
async function reportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId,
|
||||
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every server's report, for the step's size list. */
|
||||
async function reports() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId,
|
||||
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { reportFor, reports }
|
||||
129
server/model/backpacks/backpacks.model.js
Normal file
129
server/model/backpacks/backpacks.model.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// ── Backpacks, as the site sees it (PLAN_REDESIGNS §11.3.2, D199, D337, D338) ─
|
||||
//
|
||||
// WhiteThunder's Backpacks is optional (D199): a server reports it in hello's
|
||||
// `integrations.backpacks`, with the sizes its `backpacks.size.N` permissions
|
||||
// offer, and every surface that needs it refuses with a reason where it is
|
||||
// absent. Two readers share this file:
|
||||
//
|
||||
// • the step (`rust.backpack.size`, in eventRewards.js), which leases one of
|
||||
// those sizes for a run (D337) and needs the list to check its param;
|
||||
// • the staff view and the player's own page, which read one backpack live.
|
||||
//
|
||||
// **Live, never stored.** Each view is one `backpacks.read`, and nothing about
|
||||
// a backpack is written to the database. The plugin only reads (R2).
|
||||
|
||||
const client = require('../../sidecarClient')
|
||||
const db = require('./backpacks.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const { pluginError, transportError } = require('../../eventLeases')
|
||||
|
||||
/** The sentence every surface says where a server has no Backpacks (D199). */
|
||||
const MISSING = 'Backpacks is not installed on this server'
|
||||
|
||||
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
|
||||
function parse(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's Backpacks as its last hello said: `{ loaded: false }`, or loaded
|
||||
* with its version and its sizes, ascending and whole.
|
||||
*/
|
||||
function shape(raw) {
|
||||
const b = parse(raw)
|
||||
if (!b || b.loaded !== true) return { loaded: false, sizes: [] }
|
||||
|
||||
const sizes = [...new Set((Array.isArray(b.sizes) ? b.sizes : []).map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
sizes.sort((x, y) => x - y)
|
||||
|
||||
return { loaded: true, version: typeof b.version === 'string' ? b.version : null, sizes }
|
||||
}
|
||||
|
||||
/** One server's report. `{ loaded: false }` for a server never heard from. */
|
||||
async function forServer(serverId) {
|
||||
const row = await db.reportFor(serverId)
|
||||
return shape(row && row.backpacks)
|
||||
}
|
||||
|
||||
/** Every server's report, keyed by id. */
|
||||
async function byServer() {
|
||||
const rows = await db.reports()
|
||||
return new Map(rows.map((r) => [r.serverId, shape(r.backpacks)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sizes a step may lease on a server: every size but the smallest, which
|
||||
* is the default everybody holds and so gives nobody anything.
|
||||
*/
|
||||
function leasable(report) {
|
||||
return report && report.loaded ? report.sizes.slice(1) : []
|
||||
}
|
||||
|
||||
/** One stack as the page draws it; nested `contents` keep the shape one level deep. */
|
||||
function stack(raw, nest) {
|
||||
const row = {
|
||||
slot: Number(raw.slot) || 0,
|
||||
shortname: typeof raw.shortname === 'string' ? raw.shortname : null,
|
||||
display: typeof raw.display === 'string' ? raw.display : null,
|
||||
amount: Number(raw.amount) || 0,
|
||||
}
|
||||
if (raw.itemId !== undefined) row.itemId = Number(raw.itemId)
|
||||
if (typeof raw.blueprint === 'string') row.blueprint = raw.blueprint
|
||||
// A skin id is past 2^53 more often than not; it is a label here, never a number to do sums with.
|
||||
if (raw.skin !== undefined && raw.skin !== null) row.skin = String(raw.skin)
|
||||
if (typeof raw.name === 'string' && raw.name) row.name = raw.name
|
||||
if (raw.overflow === true) row.overflow = true
|
||||
if (nest && Array.isArray(raw.contents) && raw.contents.length) row.contents = raw.contents.filter(Boolean).map((c) => stack(c, false))
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* One player's backpack on one server, read live.
|
||||
*
|
||||
* Resolves `{ ok: true, backpack }` — `{ serverId, server, steamId, exists,
|
||||
* capacity, pageSize, items }`, the last three only when it exists — or
|
||||
* `{ ok: false, status, message, reason? }`: 404 for no such server, 409 for a
|
||||
* server without Backpacks, 503 for one that could not be asked.
|
||||
*/
|
||||
async function read({ serverId, steamId }) {
|
||||
const server = await servers.getForCalling(serverId)
|
||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
||||
|
||||
const name = server.name || server.id
|
||||
const result = await client.backpacksRead(server, String(steamId))
|
||||
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'backpacks') }
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
if (data.kind === 'backpacks.contents') {
|
||||
const base = { serverId: server.id, server: name, steamId: String(steamId) }
|
||||
if (data.exists !== true) return { ok: true, backpack: { ...base, exists: false } }
|
||||
return {
|
||||
ok: true,
|
||||
backpack: {
|
||||
...base,
|
||||
exists: true,
|
||||
capacity: Number(data.capacity) || 0,
|
||||
pageSize: Number(data.pageSize) || 48,
|
||||
items: (Array.isArray(data.items) ? data.items : []).filter(Boolean).map((s) => stack(s, true)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reason === 'backpacks-missing') return { ok: false, status: 409, reason: data.reason, message: `${MISSING} (${name})` }
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: data.reason === 'bad-id' ? 400 : 502,
|
||||
reason: data.reason || null,
|
||||
message: pluginError(data, `${name} did not answer with a backpack`),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MISSING, shape, forServer, byServer, leasable, stack, read }
|
||||
Reference in New Issue
Block a user