feat(rust): your backpack on the Rust account screen (PLAN_REDESIGNS §11.3) #56
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user