Merge pull request 'feat(rust): your backpack on the Rust account screen (PLAN_REDESIGNS §11.3)' (#56) from feat/rust-backpacks into edge

Reviewed-on: #56
This commit is contained in:
2026-10-12 19:41:08 +00:00
10 changed files with 428 additions and 0 deletions

View File

@@ -3,17 +3,20 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsDto
import com.runicgateway.app.data.api.dto.RustLinkListDto
import com.runicgateway.app.data.api.dto.RustLinkRequest
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerListDto
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* A player's own Rust identity and what it earns them
@@ -82,4 +85,20 @@ interface PlayerRustApi {
*/
@GET("api/v1/player/rust/economics")
suspend fun economics(): RustEconomicsDto
/**
* The servers this site follows, on the authenticated tier — the choices the
* backpack section offers. The same list as the public one.
*/
@GET("api/v1/player/rust/servers")
suspend fun servers(): RustServerListDto
/**
* One of the caller's own backpacks on one server
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2), read live from the game.
* 409 is a server without Backpacks; a module older than protocol 14 answers
* 404, and the screen then says the site cannot show backpacks yet.
*/
@GET("api/v1/player/rust/backpacks")
suspend fun backpack(@Query("serverId") serverId: String, @Query("steamId") steamId: String): RustBackpackDto
}

View File

@@ -165,3 +165,41 @@ data class RustBalanceDto(
val balance: Double? = null,
val source: String? = null,
)
/**
* `GET /player/rust/backpacks?serverId=&steamId=` — one of the caller's own
* Backpacks backpacks on one server (`docs/modules/rust/PLAN_REDESIGNS.md`
* §11.3.2), read live and never stored. [exists] false is an account that has
* never had one, and then [capacity], [pageSize] and [items] are absent.
*/
@Serializable
data class RustBackpackDto(
val serverId: String = "",
val server: String = "",
val steamId: String = "",
val exists: Boolean = false,
val capacity: Int = 0,
val pageSize: Int = 48,
val items: List<RustBackpackStackDto> = emptyList(),
)
/**
* One stack. [slot] runs across pages; [display] is the game's English name
* (D340), and for a blueprint the name of what it makes, with [blueprint] that
* item's shortname (D341). [overflow] is a stack past the capacity, which moves
* to the owner's inventory the next time they open the backpack (D338).
* [contents] is one level deep: a weapon's attachments, a container's items.
*/
@Serializable
data class RustBackpackStackDto(
val slot: Int = 0,
val shortname: String? = null,
val display: String? = null,
val amount: Int = 0,
val itemId: Int? = null,
val blueprint: String? = null,
val skin: String? = null,
val name: String? = null,
val overflow: Boolean = false,
val contents: List<RustBackpackStackDto> = emptyList(),
)

View File

@@ -7,11 +7,13 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustLinkRequest
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerDto
import javax.inject.Inject
import javax.inject.Singleton
@@ -53,4 +55,12 @@ class PlayerRustRepository @Inject constructor(
/** The caller's coin balances per server and linked account (§11.2.2). */
suspend fun economics(): ApiResult<List<RustEconomicsServerDto>> =
safeApiCall { api.economics() }.map { it.servers }
/** The servers the backpack section offers (§11.3.2). */
suspend fun servers(): ApiResult<List<RustServerDto>> =
safeApiCall { api.servers() }.map { it.servers }
/** One of the caller's own backpacks, read live (§11.3.2). Never cached: it is the game's state now. */
suspend fun backpack(serverId: String, steamId: String): ApiResult<RustBackpackDto> =
safeApiCall { api.backpack(serverId, steamId) }
}

View File

@@ -0,0 +1,47 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustBackpackStackDto
/**
* A backpack as a grid (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2) — the
* same rules as the website's `lib/backpack.js`, pure so the tests can ask them.
*
* A stack's slot runs across pages: page two of a 96-slot backpack starts at
* slot 48 when the page size is 48. A stack at or past the capacity is
* overflow, waiting for the owner's next open to be moved out (D338), so the
* grid draws those slots too, after the backpack's own, marked.
*/
data class BackpackCell(val slot: Int, val stack: RustBackpackStackDto?, val overflow: Boolean)
data class BackpackPage(val index: Int, val cells: List<BackpackCell>)
/** Enough slots for the capacity and every stack; a backpack that does not exist is no pages. */
fun backpackPages(backpack: RustBackpackDto?): List<BackpackPage> {
if (backpack == null || !backpack.exists) return emptyList()
val capacity = backpack.capacity.coerceAtLeast(0)
val pageSize = backpack.pageSize.coerceAtLeast(1)
val bySlot = backpack.items.associateBy { it.slot }
val furthest = backpack.items.maxOfOrNull { it.slot + 1 } ?: 0
val total = maxOf(capacity, furthest)
return (0 until total step pageSize).mapIndexed { index, start ->
val end = minOf(start + pageSize, total)
BackpackPage(index, (start until end).map { slot -> BackpackCell(slot, bySlot[slot], slot >= capacity) })
}
}
/**
* What a slot says (D340): the game's name, "… blueprint" for a blueprint
* (D341), then the shortname, then the bare item id for an item the game no
* longer knows.
*/
fun stackLabel(stack: RustBackpackStackDto?): String {
if (stack == null) return ""
val name = stack.display ?: stack.shortname ?: stack.itemId?.let { "item $it" } ?: "unknown item"
return if (stack.blueprint != null) "$name blueprint" else name
}

View File

@@ -3,15 +3,19 @@
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
@@ -31,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
@@ -124,6 +129,165 @@ fun RustAccountScreen(
is UiState.Error -> ErrorView(coins.kind, onRetry = viewModel::load)
is UiState.Success -> Coins(coins.data)
}
// Backpacks (PLAN_REDESIGNS §11.3.2) shipped in the same protocol
// as Economics, so the coins read's 404 gates both (R10).
Text(
stringResource(R.string.rust_backpack_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp),
)
Backpacks(state, viewModel)
}
}
}
/**
* Your own backpack, one linked account on one server at a time, read when you
* press Open and not again until you press it again (§11.3.2). No icons (D340):
* each slot is the game's own name for the item.
*/
@Composable
private fun Backpacks(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
val links = (state.links as? UiState.Success)?.data.orEmpty()
val servers = (state.backpackServers as? UiState.Success)?.data.orEmpty()
if (links.isEmpty() || servers.isEmpty()) {
Text(
stringResource(R.string.rust_backpack_unlinked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
servers.forEach { server ->
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.titleSmall)
links.forEach { link ->
val key = RustAccountViewModel.BackpackKey(server.id, link.steamId)
val open = state.backpackOpen == key
Row(verticalAlignment = Alignment.CenterVertically) {
if (links.size > 1) {
Text(
playerLabel(link.name, link.steamId),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
TextButton(onClick = { viewModel.openBackpack(server.id, link.steamId) }) {
Text(stringResource(if (open) R.string.rust_backpack_read_again else R.string.rust_backpack_open))
}
if (open) {
TextButton(onClick = viewModel::closeBackpack) {
Text(stringResource(R.string.rust_backpack_close))
}
}
}
if (open) {
when (val read = state.backpack) {
null, is UiState.Loading -> LoadingView()
is UiState.Error -> BackpackRefusal(read) { viewModel.openBackpack(server.id, link.steamId) }
is UiState.Success -> BackpackGrid(read.data)
}
}
}
}
}
}
}
/** 409 and 404 are sentences, not retries: Backpacks is missing, or the site cannot show it yet. */
@Composable
private fun BackpackRefusal(error: UiState.Error, onRetry: () -> Unit) {
val sentence = when (error.httpStatus) {
409 -> R.string.rust_backpack_missing
404 -> R.string.rust_backpack_unavailable
else -> null
}
if (sentence == null) {
ErrorView(error.kind, onRetry = onRetry)
return
}
Text(
stringResource(sentence),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
/** Pages of four-wide rows: a phone is narrow, and a slot needs room for an item's name. */
@Composable
private fun BackpackGrid(backpack: RustBackpackDto) {
if (!backpack.exists) {
Text(
stringResource(R.string.rust_backpack_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
return
}
val pages = backpackPages(backpack)
val waiting = backpack.items.count { it.overflow }
Text(
stringResource(R.string.rust_backpack_summary, backpack.items.size, backpack.capacity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
if (waiting > 0) {
Text(
stringResource(R.string.rust_backpack_overflow, waiting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
pages.forEach { page ->
if (pages.size > 1) {
Text(
stringResource(R.string.rust_backpack_page, page.index + 1),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
page.cells.chunked(BACKPACK_COLUMNS).forEach { row ->
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.forEach { cell -> BackpackSlot(cell, Modifier.weight(1f)) }
repeat(BACKPACK_COLUMNS - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private const val BACKPACK_COLUMNS = 4
@Composable
private fun BackpackSlot(cell: BackpackCell, modifier: Modifier) {
val stack = cell.stack
val outline = if (cell.overflow) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outlineVariant
Column(
modifier
.heightIn(min = 56.dp)
.border(1.dp, outline, RoundedCornerShape(4.dp))
.padding(4.dp),
) {
if (stack != null) {
Text(stackLabel(stack), style = MaterialTheme.typography.labelSmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
stack.contents.forEach { inner ->
Text("+ ${stackLabel(inner)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (stack.amount > 1) {
Text("×${stack.amount}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}

View File

@@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.repository.PlayerRustRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
@@ -48,11 +50,20 @@ class RustAccountViewModel @Inject constructor(
/** A one-shot banner for the code form, rendered beside the button that caused it. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
/** Which backpack: one linked account on one server. */
data class BackpackKey(val serverId: String, val steamId: String)
data class State(
val links: UiState<List<RustLinkDto>> = UiState.Loading,
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
/** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */
val coins: UiState<List<RustEconomicsServerDto>> = UiState.Loading,
/** The servers the backpack section offers (PLAN_REDESIGNS §11.3.2). */
val backpackServers: UiState<List<RustServerDto>> = UiState.Loading,
/** The one backpack open, by server and account, or null when none is. */
val backpackOpen: BackpackKey? = null,
/** That backpack, read live when opened; null before the first open. */
val backpack: UiState<RustBackpackDto>? = null,
val busy: Boolean = false,
val feedback: Feedback? = null,
/** The Steam id currently being released, so only its own row shows it. */
@@ -78,8 +89,31 @@ class RustAccountViewModel @Inject constructor(
}
reloadCoins()
viewModelScope.launch {
_state.update { it.copy(backpackServers = repository.servers().toUiState()) }
}
}
/**
* Open one backpack (PLAN_REDESIGNS §11.3.2): **one read per press, and
* nothing polls**, because a read runs on the game's main thread. Pressing
* the open one again reads it again; opening another closes the first.
*/
fun openBackpack(serverId: String, steamId: String) {
val key = BackpackKey(serverId, steamId)
if (_state.value.backpackOpen == key && _state.value.backpack is UiState.Loading) return
_state.update { it.copy(backpackOpen = key, backpack = UiState.Loading) }
viewModelScope.launch {
val read = repository.backpack(serverId, steamId).toUiState()
// A slow answer for a backpack the player has since closed or swapped is dropped.
_state.update { if (it.backpackOpen == key) it.copy(backpack = read) else it }
}
}
fun closeBackpack() = _state.update { it.copy(backpackOpen = null, backpack = null) }
/** The balances follow the links: a new link is a new account to read. */
private fun reloadCoins() {
viewModelScope.launch {

View File

@@ -702,6 +702,17 @@
<string name="rust_coins_missing">Economics is not installed on this server.</string>
<string name="rust_coins_balance">%1$s · %2$s coins</string>
<string name="rust_coins_last_known">Last known — the server did not answer just now.</string>
<string name="rust_backpack_title">Backpack</string>
<string name="rust_backpack_unlinked">Link a Steam account to see your backpack.</string>
<string name="rust_backpack_open">Open backpack</string>
<string name="rust_backpack_read_again">Read again</string>
<string name="rust_backpack_close">Close</string>
<string name="rust_backpack_missing">Backpacks is not installed on this server.</string>
<string name="rust_backpack_unavailable">This site cannot show backpacks yet.</string>
<string name="rust_backpack_none">No backpack here yet — it has never been opened.</string>
<string name="rust_backpack_summary">%1$d stacks in %2$d slots</string>
<string name="rust_backpack_overflow">%1$d past your size — they move to your inventory, or drop at your feet if it is full, the next time you open it.</string>
<string name="rust_backpack_page">Page %1$d</string>
<string name="rust_reach_live">%1$s · has it</string>
<string name="rust_reach_waiting">%1$s · waiting</string>
</resources>

View File

@@ -4,11 +4,13 @@
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsDto
import com.runicgateway.app.data.api.dto.RustLinkListDto
import com.runicgateway.app.data.api.dto.RustLinkRequest
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerListDto
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
/**
@@ -80,4 +82,24 @@ class FakePlayerRustApi : PlayerRustApi {
error?.let { throw it }
return economics
}
/** The servers the backpack section offers (§11.3.2). */
var servers: RustServerListDto = RustServerListDto()
override suspend fun servers(): RustServerListDto {
error?.let { throw it }
return servers
}
/** One backpack per `serverId|steamId`; a throw of its own, so a 409 can stand alone. */
var backpacks: Map<String, RustBackpackDto> = emptyMap()
var backpackError: Throwable? = null
val backpackReads = mutableListOf<String>()
override suspend fun backpack(serverId: String, steamId: String): RustBackpackDto {
backpackReads += "$serverId|$steamId"
backpackError?.let { throw it }
error?.let { throw it }
return backpacks["$serverId|$steamId"] ?: RustBackpackDto(serverId = serverId, steamId = steamId)
}
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.rust
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustBalanceDto
import com.runicgateway.app.data.api.dto.RustEconomicsDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
@@ -13,6 +14,8 @@ import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerGrantDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustReachDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustServerListDto
import com.runicgateway.app.data.api.fake.FakePlayerRustApi
import com.runicgateway.app.data.repository.PlayerRustRepository
import com.runicgateway.app.ui.UiState
@@ -229,6 +232,32 @@ class RustAccountViewModelTest {
assertEquals("a new link is a new account to read", before + 1, api.economicsCalls)
}
@Test
fun `a backpack is read once per press, and a 409 is its own answer`() {
api.servers = RustServerListDto(listOf(RustServerDto(id = "main", name = "Main")))
api.backpacks = mapOf(
"main|7656119" to RustBackpackDto(serverId = "main", steamId = "7656119", exists = true, capacity = 12, pageSize = 48),
)
val vm = viewModel()
assertEquals("nothing is read until it is opened", 0, api.backpackReads.size)
assertEquals("main", (vm.state.value.backpackServers as UiState.Success).data.single().id)
vm.openBackpack("main", "7656119")
assertEquals(listOf("main|7656119"), api.backpackReads)
assertEquals(12, ((vm.state.value.backpack as UiState.Success).data).capacity)
assertEquals(RustAccountViewModel.BackpackKey("main", "7656119"), vm.state.value.backpackOpen)
api.backpackError = httpError(409)
vm.openBackpack("main", "7656119")
assertEquals("pressing again reads again", 2, api.backpackReads.size)
assertEquals(409, (vm.state.value.backpack as UiState.Error).httpStatus)
vm.closeBackpack()
assertEquals(null, vm.state.value.backpackOpen)
assertEquals(null, vm.state.value.backpack)
}
@Test
fun `an older module's missing route leaves the rest of the screen alone`() {
api.economicsError = httpError(404)

View File

@@ -3,6 +3,8 @@
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustBackpackStackDto
import com.runicgateway.app.data.api.dto.RustEconomicsDto
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
@@ -170,4 +172,56 @@ class RustDtoTest {
assertFalse(coins.servers[1].economics)
assertEquals("Economics is not installed on this server", coins.servers[1].reason)
}
@Test
fun `a backpack decodes, nested contents and a never-had-one included`() {
// The shape of module-rust's backpacks.model read().
val bp = json.decodeFromString<RustBackpackDto>(
"""
{"serverId":"main","server":"Main","steamId":"76561198000000001","exists":true,"capacity":12,"pageSize":48,
"items":[{"slot":2,"shortname":"rifle.ak","display":"Assault Rifle","amount":1,
"contents":[{"slot":0,"shortname":"weapon.mod.lasersight","display":"Weapon Lasersight","amount":1}]},
{"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":null,"itemId":999,"amount":1,"overflow":true}]}
""".trimIndent(),
)
assertTrue(bp.exists)
assertEquals(12, bp.capacity)
assertEquals("Weapon Lasersight", bp.items[0].contents.single().display)
assertEquals("10129", bp.items[1].skin)
assertEquals("rifle.ak", bp.items[2].blueprint)
assertNull(bp.items[3].shortname)
assertTrue(bp.items[3].overflow)
val never = json.decodeFromString<RustBackpackDto>("""{"serverId":"main","server":"Main","steamId":"1","exists":false}""")
assertFalse(never.exists)
assertTrue(never.items.isEmpty())
}
@Test
fun `a backpack's slots run across pages, overflow drawn after the capacity`() {
assertTrue(backpackPages(null).isEmpty())
assertTrue(backpackPages(RustBackpackDto(exists = false)).isEmpty())
val twoPages = backpackPages(
RustBackpackDto(exists = true, capacity = 96, pageSize = 48, items = listOf(RustBackpackStackDto(slot = 50, shortname = "note"))),
)
assertEquals(2, twoPages.size)
assertEquals(48, twoPages[1].cells.size)
assertEquals("note", twoPages[1].cells[2].stack?.shortname)
// A size revoked at the end of a run: capacity 6, notes still on what was page two.
val shrunk = backpackPages(
RustBackpackDto(exists = true, capacity = 6, pageSize = 48, items = listOf(48, 49).map { RustBackpackStackDto(slot = it, overflow = true) }),
)
assertEquals(2, shrunk.size)
assertFalse(shrunk[0].cells[5].overflow)
assertTrue(shrunk[0].cells[6].overflow)
assertEquals(2, shrunk[1].cells.size)
assertEquals("Assault Rifle blueprint", stackLabel(RustBackpackStackDto(display = "Assault Rifle", blueprint = "rifle.ak")))
assertEquals("wood", stackLabel(RustBackpackStackDto(shortname = "wood")))
assertEquals("item 999", stackLabel(RustBackpackStackDto(itemId = 999)))
}
}