feat(rust): your backpack on the Rust account screen (PLAN_REDESIGNS §11.3)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m32s
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m32s
A Backpack section under Coins: one card per server (GET /player/rust/servers), an Open button per linked account that reads GET /player/rust/backpacks once per press and never polls, and the slots as pages of four-wide rows named by the game (D340), blueprints by what they make (D341), overflow marked with the take-back sentence. A 409 says Backpacks is not installed; the section is left out with Coins on that read's 404, since both ship in protocol 14. backpackPages/stackLabel are pure (BackpackLayout.kt) and tested, with the DTO decode and the view model's open/read-again/close. Local gradle lint test assembleDebug passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -3,17 +3,20 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.api
|
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.RustEconomicsDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
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 com.runicgateway.app.data.api.dto.RustUnlinkResultDto
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player's own Rust identity and what it earns them
|
* A player's own Rust identity and what it earns them
|
||||||
@@ -82,4 +85,20 @@ interface PlayerRustApi {
|
|||||||
*/
|
*/
|
||||||
@GET("api/v1/player/rust/economics")
|
@GET("api/v1/player/rust/economics")
|
||||||
suspend fun economics(): RustEconomicsDto
|
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 balance: Double? = null,
|
||||||
val source: String? = 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.map
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.PlayerRustApi
|
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.RustEconomicsServerDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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). */
|
/** The caller's coin balances per server and linked account (§11.2.2). */
|
||||||
suspend fun economics(): ApiResult<List<RustEconomicsServerDto>> =
|
suspend fun economics(): ApiResult<List<RustEconomicsServerDto>> =
|
||||||
safeApiCall { api.economics() }.map { it.servers }
|
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
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -31,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.R
|
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.RustEconomicsServerDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
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.Error -> ErrorView(coins.kind, onRetry = viewModel::load)
|
||||||
is UiState.Success -> Coins(coins.data)
|
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 androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.RustEconomicsServerDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
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.data.repository.PlayerRustRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
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. */
|
/** 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)
|
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(
|
data class State(
|
||||||
val links: UiState<List<RustLinkDto>> = UiState.Loading,
|
val links: UiState<List<RustLinkDto>> = UiState.Loading,
|
||||||
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
|
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
|
||||||
/** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */
|
/** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */
|
||||||
val coins: UiState<List<RustEconomicsServerDto>> = UiState.Loading,
|
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 busy: Boolean = false,
|
||||||
val feedback: Feedback? = null,
|
val feedback: Feedback? = null,
|
||||||
/** The Steam id currently being released, so only its own row shows it. */
|
/** The Steam id currently being released, so only its own row shows it. */
|
||||||
@@ -78,8 +89,31 @@ class RustAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
reloadCoins()
|
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. */
|
/** The balances follow the links: a new link is a new account to read. */
|
||||||
private fun reloadCoins() {
|
private fun reloadCoins() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -702,6 +702,17 @@
|
|||||||
<string name="rust_coins_missing">Economics is not installed on this server.</string>
|
<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_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_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_live">%1$s · has it</string>
|
||||||
<string name="rust_reach_waiting">%1$s · waiting</string>
|
<string name="rust_reach_waiting">%1$s · waiting</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
package com.runicgateway.app.data.api.fake
|
package com.runicgateway.app.data.api.fake
|
||||||
|
|
||||||
import com.runicgateway.app.data.api.PlayerRustApi
|
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.RustEconomicsDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||||
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
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 com.runicgateway.app.data.api.dto.RustUnlinkResultDto
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,4 +82,24 @@ class FakePlayerRustApi : PlayerRustApi {
|
|||||||
error?.let { throw it }
|
error?.let { throw it }
|
||||||
return economics
|
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
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
import com.runicgateway.app.R
|
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.RustBalanceDto
|
||||||
import com.runicgateway.app.data.api.dto.RustEconomicsDto
|
import com.runicgateway.app.data.api.dto.RustEconomicsDto
|
||||||
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
|
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.RustPlayerGrantDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
import com.runicgateway.app.data.api.dto.RustReachDto
|
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.api.fake.FakePlayerRustApi
|
||||||
import com.runicgateway.app.data.repository.PlayerRustRepository
|
import com.runicgateway.app.data.repository.PlayerRustRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
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)
|
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
|
@Test
|
||||||
fun `an older module's missing route leaves the rest of the screen alone`() {
|
fun `an older module's missing route leaves the rest of the screen alone`() {
|
||||||
api.economicsError = httpError(404)
|
api.economicsError = httpError(404)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.rust
|
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.RustEconomicsDto
|
||||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||||
@@ -170,4 +172,56 @@ class RustDtoTest {
|
|||||||
assertFalse(coins.servers[1].economics)
|
assertFalse(coins.servers[1].economics)
|
||||||
assertEquals("Economics is not installed on this server", coins.servers[1].reason)
|
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