Merge pull request 'feat(rust): coin balances on the Rust account screen (PLAN_REDESIGNS §11.2)' (#55) from feat/rust-economics into edge
Reviewed-on: #55
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<RustEconomicsServerDto> = 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<RustBalanceDto> = 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,
|
||||
)
|
||||
|
||||
@@ -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<RustPlayerPermissionsDto> =
|
||||
safeApiCall { api.permissions() }
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
@@ -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<RustEconomicsServerDto>) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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,
|
||||
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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -697,6 +697,11 @@
|
||||
<string name="rust_held_unlinked">None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.</string>
|
||||
<string name="rust_held_waiting_note">A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.</string>
|
||||
<string name="rust_held_no_servers">No servers are configured yet.</string>
|
||||
<string name="rust_coins_title">Coins</string>
|
||||
<string name="rust_coins_unlinked">Link a Steam account to see your balance.</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_last_known">Last known — the server did not answer just now.</string>
|
||||
<string name="rust_reach_live">%1$s · has it</string>
|
||||
<string name="rust_reach_waiting">%1$s · waiting</string>
|
||||
</resources>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RustEconomicsDto>(
|
||||
"""
|
||||
{"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user