feat(rust): coin balances on the Rust account screen (PLAN_REDESIGNS §11.2) #55

Merged
whitlocktech merged 1 commits from feat/rust-economics into edge 2026-10-12 12:41:14 +00:00
11 changed files with 235 additions and 1 deletions

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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 }
}

View File

@@ -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,
)
}
}
}
}
}
}
}

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}