Merge pull request 'feat(rust): Backpacks — staff and player views, the Bigger backpack step (PLAN_REDESIGNS §11.3)' (#41) from feat/rust-backpacks into edge
Reviewed-on: #41
This commit is contained in:
@@ -44,6 +44,7 @@ rows here; the website core never learns there is more than one.
|
|||||||
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
|
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
|
||||||
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
||||||
| Player | `GET /api/v1/player/rust/economics` — your coin balances on every server, per linked account |
|
| Player | `GET /api/v1/player/rust/economics` — your coin balances on every server, per linked account |
|
||||||
|
| Player | `GET /api/v1/player/rust/backpacks?serverId=&steamId=` — your own backpack on one server, read live |
|
||||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
|
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
|
||||||
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
|
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
|
||||||
| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
|
| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
|
||||||
@@ -83,7 +84,7 @@ forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date th
|
|||||||
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
|
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
|
||||||
The server list, the server page, the Android app and `/wipe` all show it.
|
The server list, the server page, the Android app and `/wipe` all show it.
|
||||||
|
|
||||||
**Three uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
|
**Four uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
|
||||||
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
|
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
|
||||||
|
|
||||||
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
|
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
|
||||||
@@ -100,6 +101,11 @@ The server list, the server page, the Android app and `/wipe` all show it.
|
|||||||
from a user's admin page, a **Coins** leaderboard column and the **Magnate** title — both ranking
|
from a user's admin page, a **Coins** leaderboard column and the **Magnate** title — both ranking
|
||||||
only balances above the server's starting balance. Without it each of those says "Economics is not
|
only balances above the server's starting balance. Without it each of those says "Economics is not
|
||||||
installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2).
|
installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2).
|
||||||
|
- **Backpacks** (WhiteThunder, 3.17.8). With it: staff open any player's backpack from their admin
|
||||||
|
page and a player sees their own on the account page and in the app — read live, never stored, and
|
||||||
|
never written — and the **Bigger backpack** event step (`rust.backpack.size`) lends one of the
|
||||||
|
server's `backpacks.size.N` sizes until the run ends, pushed like a kit reward. Without it each of
|
||||||
|
those says "Backpacks is not installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3).
|
||||||
|
|
||||||
News and event lines said in chat can wear one styled group's title and colours — the
|
News and event lines said in chat can wear one styled group's title and colours — the
|
||||||
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no
|
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ export const playerEconomics = {
|
|||||||
list: () => req('/player/rust/economics'),
|
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 ─────────────────────────────────────────────────────────────────
|
// ── admin ─────────────────────────────────────────────────────────────────
|
||||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
// **`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
|
// 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 }),
|
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 ───────────────────────────────────────────
|
// ── core's public event calendar ───────────────────────────────────────────
|
||||||
// The one CORE route this module reads, and core published `runId` on it for
|
// 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
|
// exactly this: a map marker holds a run id and nothing else, and the calendar is
|
||||||
@@ -357,6 +370,7 @@ export default {
|
|||||||
playerLinks,
|
playerLinks,
|
||||||
playerPermissions,
|
playerPermissions,
|
||||||
playerEconomics,
|
playerEconomics,
|
||||||
|
playerBackpacks,
|
||||||
admin,
|
admin,
|
||||||
adminPermissions,
|
adminPermissions,
|
||||||
adminConfig,
|
adminConfig,
|
||||||
@@ -367,6 +381,7 @@ export default {
|
|||||||
adminUserLinks,
|
adminUserLinks,
|
||||||
adminUserPermissions,
|
adminUserPermissions,
|
||||||
adminUserEconomics,
|
adminUserEconomics,
|
||||||
|
adminUserBackpacks,
|
||||||
coreEvents,
|
coreEvents,
|
||||||
BASE,
|
BASE,
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
client/src/lib/backpack.js
Normal file
52
client/src/lib/backpack.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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 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')}.`
|
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.`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useCallback, useRef, useState } from 'react'
|
|||||||
import { ago, coins, count, duration } from '../../lib/format.js'
|
import { ago, coins, count, duration } from '../../lib/format.js'
|
||||||
import { useAsync } from '../../core.js'
|
import { useAsync } from '../../core.js'
|
||||||
import api from '../../api.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. */
|
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
||||||
function SectionTitle({ children }) {
|
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 }) {
|
export default function UserRustSections({ userId }) {
|
||||||
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
// 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).
|
// 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. */}
|
{/* Coins belong to a Steam account, so only with one linked. */}
|
||||||
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useCallback, useState } from 'react'
|
|||||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||||
import { ago, coins, shortId } from '../../lib/format.js'
|
import { ago, coins, shortId } from '../../lib/format.js'
|
||||||
import api from '../../api.js'
|
import api from '../../api.js'
|
||||||
|
import Backpack from '../../components/Backpack.jsx'
|
||||||
|
|
||||||
/** The code field, and the four answers it can produce. */
|
/** The code field, and the four answers it can produce. */
|
||||||
function LinkForm({ onLinked }) {
|
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() {
|
export default function Account() {
|
||||||
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
// `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
|
// 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>
|
<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>}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
62
client/test/backpack.test.js
Normal file
62
client/test/backpack.test.js
Normal 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), '')
|
||||||
|
})
|
||||||
@@ -126,6 +126,11 @@
|
|||||||
"path": "/api/v1/admin/rust/zones",
|
"path": "/api/v1/admin/rust/zones",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/rust/backpacks",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/users/:id/rust/economics",
|
"path": "/api/v1/admin/users/:id/rust/economics",
|
||||||
@@ -141,6 +146,11 @@
|
|||||||
"path": "/api/v1/admin/users/:id/rust/permissions",
|
"path": "/api/v1/admin/users/:id/rust/permissions",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/rust/backpacks",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/rust/economics",
|
"path": "/api/v1/player/rust/economics",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
// rust.announce one line in a server's chat, or every server's
|
// rust.announce one line in a server's chat, or every server's
|
||||||
// rust.coins.pay Economics coins for the people a mode picks,
|
// rust.coins.pay Economics coins for the people a mode picks,
|
||||||
// kept once paid (protocol 14, D335)
|
// 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
|
// …and the announce leg, `rust.chat`, which says a published news post in the
|
||||||
// chat of every server whose switch is on (D104).
|
// 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 linksDb = require('./model/links/links.db')
|
||||||
const emit = require('./engagement/emit')
|
const emit = require('./engagement/emit')
|
||||||
const economics = require('./model/economics/economics.model')
|
const economics = require('./model/economics/economics.model')
|
||||||
|
const backpacks = require('./model/backpacks/backpacks.model')
|
||||||
const voice = require('./model/permissions/voice')
|
const voice = require('./model/permissions/voice')
|
||||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||||
const { BUDGET_MS } = require('./eventWorld')
|
const { BUDGET_MS } = require('./eventWorld')
|
||||||
@@ -112,6 +116,13 @@ const BUDGETS = [
|
|||||||
description:
|
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.',
|
'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. */
|
/** 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
|
* 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
|
* 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: 'chat', label: 'Chat' },
|
||||||
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
|
{ 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',
|
id: 'rust.options.chatservers',
|
||||||
label: 'Chat servers',
|
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 = {
|
module.exports = {
|
||||||
MAX_RECIPIENTS,
|
MAX_RECIPIENTS,
|
||||||
@@ -1171,6 +1399,8 @@ module.exports = {
|
|||||||
rewardNotice,
|
rewardNotice,
|
||||||
coinsNotice,
|
coinsNotice,
|
||||||
paymentKey,
|
paymentKey,
|
||||||
|
backpackNotice,
|
||||||
|
BACKPACK_NOTICE,
|
||||||
chatLine,
|
chatLine,
|
||||||
chatKey,
|
chatKey,
|
||||||
lineBody,
|
lineBody,
|
||||||
|
|||||||
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 }
|
||||||
@@ -9,6 +9,7 @@ const crypto = require('node:crypto')
|
|||||||
|
|
||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const backpacks = require('../../model/backpacks/backpacks.model')
|
||||||
const economics = require('../../model/economics/economics.model')
|
const economics = require('../../model/economics/economics.model')
|
||||||
const links = require('../../model/links/links.model')
|
const links = require('../../model/links/links.model')
|
||||||
const permissionsDb = require('../../model/permissions/permissions.db')
|
const permissionsDb = require('../../model/permissions/permissions.db')
|
||||||
@@ -275,4 +276,26 @@ async function adjustEconomics(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics }
|
/**
|
||||||
|
* GET /admin/users/:id/rust/backpacks — one of this user's backpacks
|
||||||
|
* (§11.3.2), read live. Scoped by the user like every route in this panel: a
|
||||||
|
* Steam id they have not linked is a 404, never a stranger's backpack.
|
||||||
|
*/
|
||||||
|
async function readBackpack(req, res) {
|
||||||
|
const userId = Number(req.params.id)
|
||||||
|
const { serverId, steamId } = req.query
|
||||||
|
try {
|
||||||
|
const held = await links.listForUser(userId)
|
||||||
|
if (!held.some((l) => String(l.steamId) === String(steamId))) {
|
||||||
|
return res.status(404).json({ message: 'That Steam account is not linked to this user' })
|
||||||
|
}
|
||||||
|
const result = await backpacks.read({ serverId, steamId })
|
||||||
|
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||||
|
return res.json(result.backpack)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('failed to read a backpack', { userId, serverId, error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Failed to read that backpack' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics, readBackpack }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
const express = core.express
|
const express = core.express
|
||||||
const { body, param } = core.validator
|
const { body, param, query } = core.validator
|
||||||
|
|
||||||
const usersRust = require('./usersRust.controller')
|
const usersRust = require('./usersRust.controller')
|
||||||
const { validate } = core.middleware
|
const { validate } = core.middleware
|
||||||
@@ -171,4 +171,29 @@ usersRustRouter.post(
|
|||||||
usersRust.adjustEconomics,
|
usersRust.adjustEconomics,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Backpacks (PLAN_REDESIGNS §11.3.2): what a person carries, read live ──
|
||||||
|
//
|
||||||
|
// Read only (R2): there is no write beside it, here or in the game. Each open
|
||||||
|
// of the panel is one read; nothing is stored.
|
||||||
|
|
||||||
|
usersRustRouter.get(
|
||||||
|
'/rust/backpacks',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s backpack on one server (admin only)'
|
||||||
|
// #swagger.description = 'One of this user’s linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored. The same shape as GET /player/rust/backpacks. Staff see the overflow too; only the owner’s own open of the backpack moves it.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
|
||||||
|
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of this user’s linked Steam ids.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
query('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||||
|
query('steamId').isString().matches(STEAM_ID_RE),
|
||||||
|
validate,
|
||||||
|
usersRust.readBackpack,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = usersRustRouter
|
module.exports = usersRustRouter
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const backpacks = require('../../model/backpacks/backpacks.model')
|
||||||
const economics = require('../../model/economics/economics.model')
|
const economics = require('../../model/economics/economics.model')
|
||||||
const links = require('../../model/links/links.model')
|
const links = require('../../model/links/links.model')
|
||||||
const npcs = require('../../model/npcs/npcs.model')
|
const npcs = require('../../model/npcs/npcs.model')
|
||||||
@@ -70,6 +71,28 @@ async function ownEconomics(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /player/rust/backpacks — one of the caller's own backpacks (§11.3.2),
|
||||||
|
* read live. Only an account the caller has linked: anybody else's is a 404,
|
||||||
|
* the same answer as an id nobody holds, so the route says nothing about
|
||||||
|
* whose ids exist.
|
||||||
|
*/
|
||||||
|
async function ownBackpack(req, res) {
|
||||||
|
const { serverId, steamId } = req.query
|
||||||
|
try {
|
||||||
|
const held = await links.listForUser(req.user.id)
|
||||||
|
if (!held.some((l) => String(l.steamId) === String(steamId))) {
|
||||||
|
return res.status(404).json({ message: 'That Steam account is not linked to you' })
|
||||||
|
}
|
||||||
|
const result = await backpacks.read({ serverId, steamId })
|
||||||
|
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||||
|
return res.json(result.backpack)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('failed to read a player’s backpack', { serverId, error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Failed to read your backpack' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||||||
async function listLinks(req, res) {
|
async function listLinks(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -202,4 +225,4 @@ async function listPermissions(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics }
|
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics, ownBackpack }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
const express = core.express
|
const express = core.express
|
||||||
const { body, param } = core.validator
|
const { body, param, query } = core.validator
|
||||||
|
|
||||||
const rust = require('./rust.controller')
|
const rust = require('./rust.controller')
|
||||||
const { validate, rateLimit } = core.middleware
|
const { validate, rateLimit } = core.middleware
|
||||||
@@ -67,6 +67,37 @@ playerRustRouter.get(
|
|||||||
rust.ownNpcKills,
|
rust.ownNpcKills,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A backpack read runs on the game's main thread (55–117 ms the first time
|
||||||
|
* after a boot, §11.3.1), so a page held open on refresh is not allowed to
|
||||||
|
* become a load on the server. Generous for a person, tight for a loop.
|
||||||
|
*/
|
||||||
|
const backpackLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
label: 'rust-backpack-read',
|
||||||
|
message: 'Too many backpack reads. Please wait a minute.',
|
||||||
|
})
|
||||||
|
|
||||||
|
playerRustRouter.get(
|
||||||
|
'/backpacks',
|
||||||
|
// #swagger.tags = ['Player · Rust']
|
||||||
|
// #swagger.summary = 'Your backpack on one server'
|
||||||
|
// #swagger.description = 'One of the caller’s own linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored (PLAN_REDESIGNS §11.3.2). `exists: false` for an account that has never had one. Each stack has its slot, shortname, the game’s English name (`display`), amount, and skin, custom name, blueprint target and nested contents where set; `overflow` marks a stack past the capacity, which moves to the owner’s inventory the next time they open the backpack. 404 for a Steam account the caller has not linked; 409 where the server has no Backpacks. Requires a session; 20 reads a minute.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
|
||||||
|
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of the caller’s linked Steam ids.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such server, or not one of your linked Steam accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
backpackLimiter,
|
||||||
|
query('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||||
|
query('steamId').isString().matches(STEAM_ID_RE),
|
||||||
|
validate,
|
||||||
|
rust.ownBackpack,
|
||||||
|
)
|
||||||
|
|
||||||
playerRustRouter.get(
|
playerRustRouter.get(
|
||||||
'/economics',
|
'/economics',
|
||||||
// #swagger.tags = ['Player · Rust']
|
// #swagger.tags = ['Player · Rust']
|
||||||
|
|||||||
@@ -474,6 +474,13 @@ const economicsBalances = (server, steamIds) =>
|
|||||||
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
|
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
|
||||||
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
|
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One player's backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2), read live.
|
||||||
|
* `backpacks.contents` has `exists`, and with a backpack its capacity, page
|
||||||
|
* size and stacks; `backpacks.error` has a reason. Nothing is written.
|
||||||
|
*/
|
||||||
|
const backpacksRead = (server, steamId) => request(server, '/backpacks/read', { method: 'POST', body: { steamId } })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
|
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
|
||||||
* key was said in the last ten minutes — a retry, answered and not repeated.
|
* key was said in the last ten minutes — a retry, answered and not repeated.
|
||||||
@@ -566,6 +573,7 @@ module.exports = {
|
|||||||
economicsPay,
|
economicsPay,
|
||||||
economicsBalances,
|
economicsBalances,
|
||||||
economicsAdjust,
|
economicsAdjust,
|
||||||
|
backpacksRead,
|
||||||
chat,
|
chat,
|
||||||
titles,
|
titles,
|
||||||
mapInfo,
|
mapInfo,
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ module.exports = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { loaded: { type: 'boolean', example: false } },
|
properties: { loaded: { type: 'boolean', example: false } },
|
||||||
},
|
},
|
||||||
|
backpacks: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Protocol 14 (PLAN_REDESIGNS §11.3.2): `sizes` is every registered backpacks.size.N, ascending; a Bigger backpack step may lease any above the first.',
|
||||||
|
properties: {
|
||||||
|
loaded: { type: 'boolean', example: true },
|
||||||
|
version: { type: 'string', example: '3.17.8' },
|
||||||
|
sizes: { type: 'array', items: { type: 'integer' }, example: [6, 12, 18, 24, 30, 36, 42, 48, 96, 144] },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1331,6 +1340,35 @@ module.exports = {
|
|||||||
repeat: { type: 'boolean', example: false },
|
repeat: { type: 'boolean', example: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
RustBackpackStack: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'One stack in a backpack (PLAN_REDESIGNS §11.3.2). `display` is the game’s English name (D340); for a blueprint it is the name of what it makes, and `blueprint` that item’s shortname (D341). `shortname` is null, with `itemId`, for an item the game does not know.',
|
||||||
|
properties: {
|
||||||
|
slot: { type: 'integer', example: 2, description: 'Runs across pages: the first slot of page two is pageSize.' },
|
||||||
|
shortname: { type: 'string', nullable: true, example: 'rifle.ak' },
|
||||||
|
display: { type: 'string', nullable: true, example: 'Assault Rifle' },
|
||||||
|
amount: { type: 'integer', example: 1 },
|
||||||
|
itemId: { type: 'integer', example: 1545779598 },
|
||||||
|
blueprint: { type: 'string', example: 'rifle.ak' },
|
||||||
|
skin: { type: 'string', example: '10129', description: 'A workshop skin id, as text.' },
|
||||||
|
name: { type: 'string', example: 'spike-a', description: 'A renamed item’s name.' },
|
||||||
|
overflow: { type: 'boolean', example: true, description: 'Past the capacity: it moves to the owner’s inventory, or the ground, at their next open (D338).' },
|
||||||
|
contents: { type: 'array', description: 'A weapon’s attachments or a container’s items, one level deep, without `contents` or `overflow` of their own.', items: { type: 'object' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RustBackpack: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'One Steam account’s backpack on one server, read live and never stored. `capacity`, `pageSize` and `items` are present only when `exists`.',
|
||||||
|
properties: {
|
||||||
|
serverId: { type: 'string', example: 'main' },
|
||||||
|
server: { type: 'string', example: 'Main' },
|
||||||
|
steamId: { type: 'string', example: '76561198000000001' },
|
||||||
|
exists: { type: 'boolean', example: true, description: 'False for an account that has never had a backpack.' },
|
||||||
|
capacity: { type: 'integer', example: 12 },
|
||||||
|
pageSize: { type: 'integer', example: 48 },
|
||||||
|
items: { type: 'array', items: { $ref: '#/components/schemas/RustBackpackStack' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
RustVisibilityUpdate: {
|
RustVisibilityUpdate: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
||||||
|
|||||||
325
server/test/backpacks.test.js
Normal file
325
server/test/backpacks.test.js
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
// ── 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()
|
||||||
|
|
||||||
|
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 server’s sizes above its smallest, grouped by server', 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' }]
|
||||||
|
backpacksDb.reports = async () => [
|
||||||
|
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
|
||||||
|
{ serverId: 'pve', backpacks: null },
|
||||||
|
]
|
||||||
|
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes')
|
||||||
|
assert.deepStrictEqual(await source.resolve(), [{ value: 48, label: '48 slots', group: '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')
|
||||||
|
})
|
||||||
@@ -156,6 +156,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
|||||||
const budgets = api.record.eventBudgets
|
const budgets = api.record.eventBudgets
|
||||||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
|
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
|
||||||
'rust.announce',
|
'rust.announce',
|
||||||
|
'rust.backpack.size',
|
||||||
'rust.coins.pay',
|
'rust.coins.pay',
|
||||||
'rust.crate.place',
|
'rust.crate.place',
|
||||||
'rust.kit.entitle',
|
'rust.kit.entitle',
|
||||||
@@ -166,6 +167,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
|||||||
])
|
])
|
||||||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
||||||
'rust.announcements',
|
'rust.announcements',
|
||||||
|
'rust.backpacks',
|
||||||
'rust.coins',
|
'rust.coins',
|
||||||
'rust.grants',
|
'rust.grants',
|
||||||
'rust.npcs',
|
'rust.npcs',
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ test('the player tier serves the identity routes, the entitlement read, and noth
|
|||||||
routes.map((r) => `${r.method} ${r.path}`).sort(),
|
routes.map((r) => `${r.method} ${r.path}`).sort(),
|
||||||
[
|
[
|
||||||
'DELETE /links/:steamId',
|
'DELETE /links/:steamId',
|
||||||
|
// Protocol 14 (§11.3.2): the caller's own backpack, read live. Read-only.
|
||||||
|
'GET /backpacks',
|
||||||
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
|
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
|
||||||
'GET /economics',
|
'GET /economics',
|
||||||
'GET /links',
|
'GET /links',
|
||||||
@@ -90,6 +92,8 @@ test('the admin.users.detail router merges the parent’s params and keeps its o
|
|||||||
'DELETE /rust/links/:steamId',
|
'DELETE /rust/links/:steamId',
|
||||||
// Phase 7 filled the same panel with what this person may do in game.
|
// Phase 7 filled the same panel with what this person may do in game.
|
||||||
'DELETE /rust/permissions/grants/:grantId',
|
'DELETE /rust/permissions/grants/:grantId',
|
||||||
|
// Protocol 14 (§11.3.2): what they carry, read live. There is no write.
|
||||||
|
'GET /rust/backpacks',
|
||||||
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
|
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
|
||||||
'GET /rust/economics',
|
'GET /rust/economics',
|
||||||
'GET /rust/links',
|
'GET /rust/links',
|
||||||
|
|||||||
@@ -2205,6 +2205,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/users/{id}/rust/backpacks": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Users"
|
||||||
|
],
|
||||||
|
"summary": "A user’s backpack on one server (admin only)",
|
||||||
|
"description": "One of this user’s linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored. The same shape as GET /player/rust/backpacks. Staff see the overflow too; only the owner’s own open of the backpack moves it.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "User id."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "serverId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "The server."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "steamId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "One of this user’s linked Steam ids."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The backpack",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RustBackpack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such server, or that Steam account is not linked to this user",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Backpacks is not installed on that server",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "The server could not be reached",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/users/{id}/rust/economics": {
|
"/api/v1/admin/users/{id}/rust/economics": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2611,6 +2702,88 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/player/rust/backpacks": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Player · Rust"
|
||||||
|
],
|
||||||
|
"summary": "Your backpack on one server",
|
||||||
|
"description": "One of the caller’s own linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored (PLAN_REDESIGNS §11.3.2). `exists: false` for an account that has never had one. Each stack has its slot, shortname, the game’s English name (`display`), amount, and skin, custom name, blueprint target and nested contents where set; `overflow` marks a stack past the capacity, which moves to the owner’s inventory the next time they open the backpack. 404 for a Steam account the caller has not linked; 409 where the server has no Backpacks. Requires a session; 20 reads a minute.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "serverId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "The server."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "steamId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "One of the caller’s linked Steam ids."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The backpack",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RustBackpack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such server, or not one of your linked Steam accounts",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Backpacks is not installed on that server",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "The server could not be reached",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/player/rust/economics": {
|
"/api/v1/player/rust/economics": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5014,6 +5187,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"backpacks": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Protocol 14 (PLAN_REDESIGNS §11.3.2): `sizes` is every registered backpacks.size.N, ascending; a Bigger backpack step may lease any above the first."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"loaded": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "boolean"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "3.17.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "array"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "array",
|
||||||
|
"example": [
|
||||||
|
6,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
24,
|
||||||
|
30,
|
||||||
|
36,
|
||||||
|
42,
|
||||||
|
48,
|
||||||
|
96,
|
||||||
|
144
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13346,6 +13599,299 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RustBackpackStack": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "One stack in a backpack (PLAN_REDESIGNS §11.3.2). `display` is the game’s English name (D340); for a blueprint it is the name of what it makes, and `blueprint` that item’s shortname (D341). `shortname` is null, with `itemId`, for an item the game does not know."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"slot": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Runs across pages: the first slot of page two is pageSize."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shortname": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "rifle.ak"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Assault Rifle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"itemId": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1545779598
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blueprint": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "rifle.ak"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skin": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "10129"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A workshop skin id, as text."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "spike-a"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A renamed item’s name."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overflow": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "boolean"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Past the capacity: it moves to the owner’s inventory, or the ground, at their next open (D338)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contents": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "array"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A weapon’s attachments or a container’s items, one level deep, without `contents` or `overflow` of their own."
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RustBackpack": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "One Steam account’s backpack on one server, read live and never stored. `capacity`, `pageSize` and `items` are present only when `exists`."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"serverId": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"steamId": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "76561198000000001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "boolean"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "False for an account that has never had a backpack."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pageSize": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "array"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RustBackpackStack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RustVisibilityUpdate": {
|
"RustVisibilityUpdate": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Reference in New Issue
Block a user