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 7ae5f89..1a78163 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,6 +3,7 @@ */ package com.runicgateway.app.data.api +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 @@ -72,4 +73,13 @@ interface PlayerRustApi { */ @GET("api/v1/player/rust/permissions") suspend fun permissions(): RustPlayerPermissionsDto + + /** + * The caller's coin balances on every server, per linked account + * (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2). Live where a server + * answers, the last balance it reported where it does not. A module older + * than protocol 14 answers 404, and the screen then shows no coins at all. + */ + @GET("api/v1/player/rust/economics") + suspend fun economics(): RustEconomicsDto } 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 ddd822c..bd3042e 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 @@ -125,3 +125,43 @@ data class RustReachDto( val name: String = "", val live: Boolean = false, ) + +/** + * `GET /player/rust/economics` — the caller's coin balances + * (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): every server, each linked + * Steam account. Economics is optional on a server, so one without it is listed + * with [RustEconomicsServerDto.economics] false rather than left out. + */ +@Serializable +data class RustEconomicsDto( + val servers: List = emptyList(), +) + +/** + * One server's balances. [startingKnown] false means the server's Economics + * config could not be read, and [startingBalance] is Economics' default. + */ +@Serializable +data class RustEconomicsServerDto( + val serverId: String = "", + val server: String = "", + val economics: Boolean = false, + val reason: String? = null, + val startingBalance: Double? = null, + val startingKnown: Boolean = true, + val live: Boolean = false, + val accounts: List = emptyList(), +) + +/** + * One linked account's balance on one server. [source] is `live` (the server + * answered just now), `last-known` (it did not, and this is the last balance it + * reported), or null with [balance] null when neither knows. + */ +@Serializable +data class RustBalanceDto( + val steamId: String = "", + val name: String? = null, + val balance: Double? = null, + val source: String? = null, +) 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 29f696a..f82e83f 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,6 +7,7 @@ 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.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 @@ -48,4 +49,8 @@ class PlayerRustRepository @Inject constructor( /** Ranks and grants the site holds for the caller, resolved per server. */ suspend fun permissions(): ApiResult = safeApiCall { api.permissions() } + + /** The caller's coin balances per server and linked account (§11.2.2). */ + suspend fun economics(): ApiResult> = + safeApiCall { api.economics() }.map { it.servers } } 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 d811df5..d6eada5 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 @@ -31,6 +31,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.RustEconomicsServerDto import com.runicgateway.app.data.api.dto.RustLinkDto import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto import com.runicgateway.app.data.api.dto.RustReachDto @@ -106,6 +107,73 @@ fun RustAccountScreen( is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load) is UiState.Success -> Held(held.data) } + + // Coins (PLAN_REDESIGNS §11.2.2). A 404 is a module older than protocol + // 14, which has no balances to give: the section is left out rather than + // shown as an error the player can do nothing about. + val coins = state.coins + if (!(coins is UiState.Error && coins.httpStatus == 404)) { + Text( + stringResource(R.string.rust_coins_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + + when (coins) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(coins.kind, onRetry = viewModel::load) + is UiState.Success -> Coins(coins.data) + } + } + } +} + +/** + * One card per server: each linked account's balance where the server runs + * Economics, and the reason where it does not (D199) — a player asking where + * their coins are is owed the answer, not an empty space. + */ +@Composable +private fun Coins(servers: List) { + if (servers.none { it.accounts.isNotEmpty() }) { + Text( + stringResource(R.string.rust_coins_unlinked), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + servers.forEach { server -> + ShardCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(server.server.ifBlank { server.serverId }, style = MaterialTheme.typography.titleSmall) + + if (!server.economics) { + Text( + stringResource(R.string.rust_coins_missing), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } else { + server.accounts.forEach { account -> + Text( + stringResource(R.string.rust_coins_balance, playerLabel(account.name, account.steamId), coinAmount(account.balance)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + ) + if (account.source == "last-known") { + Text( + stringResource(R.string.rust_coins_last_known), + style = MaterialTheme.typography.bodySmall, + 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 ce0d90b..95bb457 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,6 +8,7 @@ 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.RustEconomicsServerDto import com.runicgateway.app.data.api.dto.RustLinkDto import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto import com.runicgateway.app.data.repository.PlayerRustRepository @@ -50,6 +51,8 @@ class RustAccountViewModel @Inject constructor( 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, val busy: Boolean = false, val feedback: Feedback? = null, /** The Steam id currently being released, so only its own row shows it. */ @@ -64,7 +67,7 @@ class RustAccountViewModel @Inject constructor( } fun load() { - _state.update { it.copy(links = UiState.Loading, held = UiState.Loading) } + _state.update { it.copy(links = UiState.Loading, held = UiState.Loading, coins = UiState.Loading) } viewModelScope.launch { _state.update { it.copy(links = repository.links().toUiState()) } @@ -73,6 +76,15 @@ class RustAccountViewModel @Inject constructor( viewModelScope.launch { _state.update { it.copy(held = repository.permissions().toUiState()) } } + + reloadCoins() + } + + /** The balances follow the links: a new link is a new account to read. */ + private fun reloadCoins() { + viewModelScope.launch { + _state.update { it.copy(coins = repository.economics().toUiState()) } + } } /** Re-read what the site holds without blanking the accounts above it. */ @@ -146,6 +158,7 @@ class RustAccountViewModel @Inject constructor( _state.update { it.copy(links = repository.links().toUiState()) } } reloadHeld() + reloadCoins() } private fun linkErrorRes(status: Int): Int = when (status) { diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt index c18dd3b..bd6b8ae 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -159,6 +159,17 @@ fun prefabName(name: String?): String { return name.replace(Regex("[_.]+"), " ").trim() } +/** + * An Economics balance (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): grouped, + * at most two places, and a dash for one the server never reported — not "0", + * which is a claim about somebody's balance that nobody made. + */ +fun coinAmount(value: Double?, locale: java.util.Locale = java.util.Locale.getDefault()): String { + val n = value ?: return "—" + if (!n.isFinite()) return "—" + return java.text.NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 2 }.format(n) +} + /** A steam id, shortened for a table cell, without pretending it is a name. */ fun shortSteamId(steamId: String?): String { val id = steamId.orEmpty() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f9e791..9b1fb9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -697,6 +697,11 @@ None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync. A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back. No servers are configured yet. + Coins + Link a Steam account to see your balance. + Economics is not installed on this server. + %1$s · %2$s coins + Last known — the server did not answer just now. %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 d245d0e..430e495 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,6 +4,7 @@ package com.runicgateway.app.data.api.fake import com.runicgateway.app.data.api.PlayerRustApi +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 @@ -67,4 +68,16 @@ class FakePlayerRustApi : PlayerRustApi { error?.let { throw it } return permissions } + + /** The coin balances (§11.2.2), and a throw of their own: a 404 is an older module. */ + var economics: RustEconomicsDto = RustEconomicsDto() + var economicsError: Throwable? = null + var economicsCalls: Int = 0 + + override suspend fun economics(): RustEconomicsDto { + economicsCalls++ + economicsError?.let { throw it } + error?.let { throw it } + return economics + } } 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 9a8655e..5ddff48 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,9 @@ package com.runicgateway.app.ui.rust import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.RustBalanceDto +import com.runicgateway.app.data.api.dto.RustEconomicsDto +import com.runicgateway.app.data.api.dto.RustEconomicsServerDto import com.runicgateway.app.data.api.dto.RustLinkDto import com.runicgateway.app.data.api.dto.RustLinkListDto import com.runicgateway.app.data.api.dto.RustLinkResultDto @@ -202,4 +205,38 @@ class RustAccountViewModelTest { assertTrue(held.grants.first().reach.first { it.id == "main" }.live) assertFalse(held.grants.first().reach.first { it.id == "creative" }.live) } + + @Test + fun `coins are a third read, and a link re-reads them`() { + api.economics = RustEconomicsDto( + servers = listOf( + RustEconomicsServerDto( + serverId = "main", + server = "Main", + economics = true, + accounts = listOf(RustBalanceDto(steamId = "7656119", balance = 1500.0, source = "live")), + ), + ), + ) + + val vm = viewModel() + val coins = vm.state.value.coins + assertTrue(coins is UiState.Success) + assertEquals(1500.0, (coins as UiState.Success).data.single().accounts.single().balance) + + val before = api.economicsCalls + vm.link("K7M2PQ") + assertEquals("a new link is a new account to read", before + 1, api.economicsCalls) + } + + @Test + fun `an older module's missing route leaves the rest of the screen alone`() { + api.economicsError = httpError(404) + api.permissions = RustPlayerPermissionsDto(accounts = 1) + + val state = viewModel().state.value + + assertTrue(state.held is UiState.Success) + assertEquals(404, (state.coins as UiState.Error).httpStatus) + } } 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 a55316a..1835942 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,7 @@ */ package com.runicgateway.app.ui.rust +import com.runicgateway.app.data.api.dto.RustEconomicsDto import com.runicgateway.app.data.api.dto.RustEventDto import com.runicgateway.app.data.api.dto.RustLeaderboardDto import com.runicgateway.app.data.api.dto.RustServerListDto @@ -146,4 +147,27 @@ class RustDtoTest { ) assertTrue(older.leaderboard.single().titles.isEmpty()) } + + @Test + fun `coin balances decode, a server without Economics included`() { + // The shape of module-rust's economics.model balancesOf(). + val coins = json.decodeFromString( + """ + {"servers":[ + {"serverId":"main","server":"Main","economics":true,"startingBalance":1000,"startingKnown":false,"live":false, + "accounts":[{"steamId":"76561198000000001","name":"Wanderer","balance":1500.5,"source":"last-known"}, + {"steamId":"76561198000000002","name":null,"balance":null,"source":null}]}, + {"serverId":"pvp","server":"PvP","economics":false,"reason":"Economics is not installed on this server","accounts":[]} + ]} + """.trimIndent(), + ) + val main = coins.servers.first() + assertTrue(main.economics) + assertFalse(main.startingKnown) + assertEquals(1500.5, main.accounts.first().balance!!, 0.0) + assertEquals("last-known", main.accounts.first().source) + assertNull(main.accounts[1].balance) + assertFalse(coins.servers[1].economics) + assertEquals("Economics is not installed on this server", coins.servers[1].reason) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt index 35a2427..06e8650 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -169,4 +169,12 @@ class RustFormatTest { assertTrue(titleInkIsDark(titleArgb("#ff0000")!!)) assertFalse(titleInkIsDark(titleArgb("#1a1a8c")!!)) } + + @Test + fun `a balance never reported is a dash, not zero coins`() { + assertEquals("—", coinAmount(null, Locale.UK)) + assertEquals("—", coinAmount(Double.NaN, Locale.UK)) + assertEquals("0", coinAmount(0.0, Locale.UK)) + assertEquals("1,500.46", coinAmount(1500.456, Locale.UK)) + } }