diff --git a/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt index 1a78163..2faa596 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt @@ -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 } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt index bd3042e..738dba9 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt @@ -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 = 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 = emptyList(), +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt index f82e83f..d3883f4 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt @@ -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> = safeApiCall { api.economics() }.map { it.servers } + + /** The servers the backpack section offers (§11.3.2). */ + suspend fun servers(): ApiResult> = + 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 = + safeApiCall { api.backpack(serverId, steamId) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/BackpackLayout.kt b/app/src/main/java/com/runicgateway/app/ui/rust/BackpackLayout.kt new file mode 100644 index 0000000..3e8f33d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/BackpackLayout.kt @@ -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) + +/** Enough slots for the capacity and every stack; a backpack that does not exist is no pages. */ +fun backpackPages(backpack: RustBackpackDto?): List { + 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 +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt index d6eada5..e13da8d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt @@ -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) + } } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt index 95bb457..eeec11d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt @@ -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> = UiState.Loading, val held: UiState = UiState.Loading, /** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */ val coins: UiState> = UiState.Loading, + /** The servers the backpack section offers (PLAN_REDESIGNS §11.3.2). */ + val backpackServers: UiState> = 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? = 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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b1fb9a..aa896c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -702,6 +702,17 @@ Economics is not installed on this server. %1$s · %2$s coins Last known — the server did not answer just now. + Backpack + Link a Steam account to see your backpack. + Open backpack + Read again + Close + Backpacks is not installed on this server. + This site cannot show backpacks yet. + No backpack here yet — it has never been opened. + %1$d stacks in %2$d slots + %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. + Page %1$d %1$s · has it %1$s · waiting diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt index 430e495..1ad6cab 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt @@ -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 = emptyMap() + var backpackError: Throwable? = null + val backpackReads = mutableListOf() + + 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) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt index 5ddff48..b233e22 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt @@ -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) diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt index 1835942..bec8367 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt @@ -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( + """ + {"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("""{"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))) + } }