feat(rust): a player's own Rust account on the phone — M15 (module-rust phase 8, Android leg B)

R10's leg B: identity, and the half of R2 a player is allowed to see. Every
route this calls existed and answered before a line of Kotlin was written,
except the entitlement read, which is this phase's one website change.

**One drawer row under the player group, shaped like `CharactersScreen` one
game along**: the code card first, then what the code got them. A link is
fleet-wide, so it is not a tab under one server — a Steam account is one
person wherever they play, while stats are per server and per wipe.

**Gated on `rust`, not on `identity`.** The module declares a surface word
per feature, and D16's rule is that a capability answers one question — *is
the module there*. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is
right here: `/player/rust/*` is `requireAuth` with no role above it, and
staff play the game too.

**Two reads, neither blocking the other.** An entitlement is authored
against the website account, so it exists before a Steam id does — the
person who has just been given something and has not linked yet is exactly
the one who needs both halves at once, and a failure on either leaves the
other standing.

**The four refusals stay four pieces of advice.** 400 is a spent code, 409
is a Steam account another account holds (`/unlink` in game releases it),
429 is the limiter, and 503 is a server that could not be reached — where
the code is still good, so it may not say "get a new one". A player told
otherwise goes back to the same unreachable server for another code.

The app does no scope arithmetic: `*` never reaches a screen. Each entry
arrives with its servers already resolved and each marked, because a second
implementation of `inScope` is a second thing to keep true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 20:19:13 -05:00
parent 37a828736e
commit 4878b74e09
14 changed files with 1112 additions and 4 deletions

View File

@@ -0,0 +1,75 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
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.RustUnlinkResultDto
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* A player's own Rust identity and what it earns them
* (`docs/modules/rust/PLAN.md` §19, §20, §22; M15), over the bearer-gated
* `/player/rust/…` surface.
*
* Its own interface beside [RustApi] rather than four more methods on it, for
* the same reason [PlayerShardApi] is separate from [PublicApi]: these need a
* session and those do not, and one interface holding both makes the tier a
* property of the method name instead of the type.
*
* **The paths are hardcoded, which is the contract and not a shortcut.**
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
* the app cannot build `/<module id>/links` from what `GET /public/modules`
* reports. A capability answers *is the module there*; these four addresses are
* knowledge the app has because someone read the module's router.
*/
interface PlayerRustApi {
/** The Steam accounts the caller holds. Fleet-wide: a link is not per server. */
@GET("api/v1/player/rust/links")
suspend fun links(): RustLinkListDto
/**
* Redeem the code `/link` handed the player in game.
*
* **The refusals are not interchangeable and the screen must not flatten
* them.** A 400 is a code that is unknown or expired — go and get another;
* a 409 is a Steam account another website account holds — run `/unlink` in
* game; a 503 is a server that could not be reached, where the code is still
* good and the only right advice is to wait a minute. A player told to run
* `/link` again when the server their code came from was merely down will
* get another code from the same down server. The server sends a sentence
* for each; this leg renders it rather than writing one of its own.
*
* Rate-limited server-side (ten per quarter-hour per IP), so a 429 is an
* ordinary answer here rather than a bug.
*/
@POST("api/v1/player/rust/link")
suspend fun link(@Body body: RustLinkRequest): RustLinkResultDto
/**
* Release a link the caller holds.
*
* Scoped to the caller inside the server's statement, so a Steam id that is
* somebody else's answers the same 404 as one that is nobody's.
*/
@DELETE("api/v1/player/rust/links/{steamId}")
suspend fun unlink(@Path("steamId") steamId: String): RustUnlinkResultDto
/**
* What the site has given this player in game — ranks and direct grants,
* each already resolved to the servers its scope reaches.
*
* Read-only by construction: everything that authors one of these rows is an
* admin route.
*/
@GET("api/v1/player/rust/permissions")
suspend fun permissions(): RustPlayerPermissionsDto
}

View File

@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for a player's own half of `module-rust` (`docs/modules/rust/PLAN.md`
* §19, §20, §22; M15).
*
* Two surfaces, and they are deliberately separate reads rather than one:
*
* * **the links** — which Steam accounts this website account holds. A link is
* fleet-wide, because a Steam account is one person on every server an
* operator runs, while stats are per server and per wipe.
* * **what the site has given them in game** — groups and direct grants, each
* already resolved to the servers its scope reaches. It is its own read
* because an entitlement is authored against the *website* account, so it
* exists before a Steam id does; the person who has just been given something
* and has not linked yet is exactly the one who needs to see both halves at
* once.
*
* Nothing here is a write except the code redemption. A grant a player could
* change would not be a grant.
*/
/** `GET /player/rust/links` — the Steam accounts the caller holds. */
@Serializable
data class RustLinkListDto(
val links: List<RustLinkDto> = emptyList(),
)
/**
* One linked Steam account.
*
* [name] is what the player was called in game when they linked — a display
* name only, and a Rust name changes on a whim. [serverId] is where the code was
* minted, which is not part of the identity but is where a support conversation
* starts.
*/
@Serializable
data class RustLinkDto(
val steamId: String = "",
val name: String? = null,
val serverId: String? = null,
val linkedAt: String? = null,
)
/** `POST /player/rust/link` body — the six-character code `/link` gives in game. */
@Serializable
data class RustLinkRequest(val code: String)
/**
* `POST /player/rust/link` result.
*
* [already] is a second press of the button rather than an error: the code was
* good and that Steam id was already this caller's.
*/
@Serializable
data class RustLinkResultDto(
val linked: Boolean = false,
val link: RustLinkDto? = null,
val already: Boolean = false,
)
/** `DELETE /player/rust/links/{steamId}` result. */
@Serializable
data class RustUnlinkResultDto(
val unlinked: Boolean = false,
)
/**
* `GET /player/rust/permissions` — what the site has given this player in game.
*
* [accounts] is how many Steam accounts they have linked, and it is on the
* envelope for one reason: zero is why an entitlement can be authored and reach
* nobody, and the screen has to be able to say that without inferring it.
*/
@Serializable
data class RustPlayerPermissionsDto(
val accounts: Int = 0,
val groups: List<RustPlayerGroupDto> = emptyList(),
val grants: List<RustPlayerGrantDto> = emptyList(),
)
/** A rank the site holds for this player, and what it carries. */
@Serializable
data class RustPlayerGroupDto(
val name: String = "",
val title: String = "",
val scope: String = "*",
val since: String? = null,
val permissions: List<String> = emptyList(),
val reach: List<RustReachDto> = emptyList(),
)
/** One permission held directly, without a rank. */
@Serializable
data class RustPlayerGrantDto(
val permission: String = "",
val scope: String = "*",
val source: String = "admin",
val note: String? = null,
val since: String? = null,
val reach: List<RustReachDto> = emptyList(),
)
/**
* One server an entitlement's scope reaches, and whether it is there yet.
*
* **The scope arithmetic is the server's.** A client handed `scope: "*"` would
* have to know what the fleet is to say anything useful, and then the rule
* exists in two places; the website resolves it and marks each server instead.
*
* [live] is the pushed ledger rather than the authored row: a grant is not a
* privilege in a game until a sync confirmed it. `false` covers every way it has
* not arrived — the server is offline, no loaded plugin registered the name, the
* store has never seen the account — and telling those apart is an operator's
* diagnosis, not a player's.
*/
@Serializable
data class RustReachDto(
val id: String = "",
val name: String = "",
val live: Boolean = false,
)

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
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.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 javax.inject.Inject
import javax.inject.Singleton
/**
* A player's own Rust identity and what it earns them (PLAN.md §9 M15), over the
* bearer-gated `/player/rust/…` surface.
*
* Every call returns a typed [ApiResult] rather than throwing, like every other
* repository here — and on this surface the **status is the message**: the
* website answers a refused code `400`, a Steam account somebody else holds
* `409`, an unreachable server `503` and a capped attempt `429`, precisely so a
* client can tell a player what to do next without reading prose. The view model
* is where that mapping lives.
*
* Nothing is cached. The entitlement read in particular is a picture of what the
* site has confirmed into a game, and a stale copy of that would be the one kind
* of wrong answer this whole surface exists to avoid.
*/
@Singleton
class PlayerRustRepository @Inject constructor(
private val api: PlayerRustApi,
) {
/** The Steam accounts the caller holds, newest first. */
suspend fun links(): ApiResult<List<RustLinkDto>> =
safeApiCall { api.links() }.map { it.links }
/** Redeem a code from `/link` in game. */
suspend fun link(code: String): ApiResult<RustLinkResultDto> =
safeApiCall { api.link(RustLinkRequest(code)) }
/** Release one of the caller's own links. */
suspend fun unlink(steamId: String): ApiResult<Boolean> =
safeApiCall { api.unlink(steamId) }.map { it.unlinked }
/** Ranks and grants the site holds for the caller, resolved per server. */
suspend fun permissions(): ApiResult<RustPlayerPermissionsDto> =
safeApiCall { api.permissions() }
}

View File

@@ -19,6 +19,7 @@ import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.RustApi
@@ -146,6 +147,15 @@ object NetworkModule {
@Singleton
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
/**
* A player's own Rust identity and entitlements (§9 M15) — bearer-authed on
* the main client, like [providePlayerShardApi] one game along.
*/
@Provides
@Singleton
fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi =
retrofit.create(PlayerRustApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides
@Singleton

View File

@@ -103,6 +103,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.rust.RustBadgeViewModel
import com.runicgateway.app.ui.rust.RustAccountScreen
import com.runicgateway.app.ui.rust.RustServerScreen
import com.runicgateway.app.ui.rust.RustServersScreen
import com.runicgateway.app.ui.shard.ShardScreen
@@ -126,6 +127,8 @@ private val TOP_LEVEL_ROUTES = setOf(
// screen and is deliberately absent — a back gesture there means "back".
Routes.RUST,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
// The player's own Rust account (M15) — a drawer row like the three above it.
Routes.PLAYER_RUST,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
@@ -739,6 +742,12 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() }
}
// The player's own Rust account (M15). Behind the same gate as the three
// above: the route is `requireAuth` server-side, and the gate is here so a
// signed-out reader is sent home rather than left on a screen that 401s.
composable(Routes.PLAYER_RUST) {
PlayerGate(session, navController) { RustAccountScreen() }
}
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
// The backend re-checks role on every /admin/… call; these gates only mirror

View File

@@ -177,6 +177,22 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
// The player's own Rust account (M15). Beside the three UO rows above and
// gated exactly as they are: the module's `rust` capability (a row needs the
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
//
// **`rust`, not `identity`.** The module declares a surface word for each of
// its features, and D16's rule is that a capability answers one question —
// *is the module there* — so a surface word is not what a row hangs on. The
// paths these screens call are knowledge the app has from reading the
// module's router, exactly as §2.9 requires.
MenuEntry(
Routes.PLAYER_RUST,
R.string.menu_rust_account,
MenuAccess.PLAYER,
capability = Capability.RUST,
),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),

View File

@@ -86,6 +86,17 @@ object Routes {
const val RUST = "rust"
const val RUST_SERVER = "rust/servers/{serverId}"
/**
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
* what the site has given them in game.
*
* Under `player/` with the three UO rows rather than under `rust/` with the
* two public ones, because the grouping that matters to a reader is *whose
* data is this*: these are the signed-in, self-scoped screens. The website
* agrees — it serves this from the player tier, at `/player/rust`.
*/
const val PLAYER_RUST = "player/rust"
/** Public shard hub (§6.2). */
const val SHARD = "shard"

View File

@@ -0,0 +1,293 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustReachDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The player's own Rust account (PLAN.md §9 M15) — the app's mirror of the
* module's `/player/rust` page, and the same shape as
* [com.runicgateway.app.ui.player.CharactersScreen] one game along: the code
* card first, then what the code got them.
*
* Two independent reads. The accounts can fail with the entitlements on screen,
* and the other way round, because an entitlement is authored against the
* website account and exists before a Steam id does.
*/
@Composable
fun RustAccountScreen(
modifier: Modifier = Modifier,
viewModel: RustAccountViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
LinkCard(state, viewModel)
when (val links = state.links) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(links.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (links.data.isEmpty()) {
Text(
stringResource(R.string.rust_account_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
links.data.forEach { link ->
LinkRow(
link = link,
busy = state.unlinking == link.steamId,
onUnlink = { viewModel.unlink(link.steamId) },
)
}
Text(
stringResource(R.string.rust_account_fleet_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
stringResource(R.string.rust_held_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp),
)
when (val held = state.held) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load)
is UiState.Success -> Held(held.data)
}
}
}
/**
* The code form.
*
* The three-step instruction is not decoration: nothing else in the app tells a
* player that the code comes from the game, and a code field with no explanation
* is a code field nobody can use.
*/
@Composable
private fun LinkCard(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
var code by rememberSaveable { mutableStateOf("") }
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.rust_link_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.rust_link_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
OutlinedTextField(
value = code,
onValueChange = { code = it.uppercase() },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.rust_link_code)) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Button(
onClick = { viewModel.link(code); code = "" },
enabled = !state.busy && code.isNotBlank(),
modifier = Modifier.padding(top = 12.dp),
) { Text(stringResource(R.string.rust_link_action)) }
// Beside the button that caused it. A refusal at the top of a long
// scroll is a press that visibly did nothing (PLAN.md §21.5).
state.feedback?.let { feedback ->
Text(
text = stringResource(feedback.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (feedback.ok) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
modifier = Modifier.padding(top = 10.dp),
)
}
}
}
}
/** One linked Steam account, and the button that releases it. */
@Composable
private fun LinkRow(link: RustLinkDto, busy: Boolean, onUnlink: () -> Unit) {
ShardCard(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = link.name?.takeIf { it.isNotBlank() } ?: link.steamId,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = link.steamId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onUnlink, enabled = !busy) {
Text(stringResource(R.string.rust_unlink_action))
}
}
}
}
/** Ranks and grants, drawn the same way because they read the same. */
@Composable
private fun Held(held: RustPlayerPermissionsDto) {
if (held.groups.isEmpty() && held.grants.isEmpty()) {
Text(
stringResource(R.string.rust_held_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
held.groups.forEach { group ->
HeldCard(
title = group.title.ifBlank { group.name },
detail = group.permissions.joinToString(" · ").takeIf { it.isNotBlank() },
reach = group.reach,
)
}
held.grants.forEach { grant ->
HeldCard(
title = grant.permission,
detail = grant.note?.takeIf { it.isNotBlank() },
reach = grant.reach,
)
}
// Zero linked accounts is WHY everything above is waiting, and the screen
// says so rather than leaving a page of hollow pills to be read as a fault.
if (held.accounts == 0) {
Text(
stringResource(R.string.rust_held_unlinked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (held.groups.any { it.reach.any { s -> !s.live } } ||
held.grants.any { it.reach.any { s -> !s.live } }
) {
Text(
stringResource(R.string.rust_held_waiting_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun HeldCard(title: String, detail: String?, reach: List<RustReachDto>) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (reach.isEmpty()) {
Text(
stringResource(R.string.rust_held_no_servers),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 10.dp),
) {
reach.forEach { server ->
// The tone IS the state: a server that has it and one
// that has not are the two things this screen exists to
// tell apart, and colour alone would not say which — so
// the label carries the word as well.
StatusPill(
text = stringResource(
if (server.live) R.string.rust_reach_live else R.string.rust_reach_waiting,
server.name.ifBlank { server.id },
),
tone = if (server.live) PillTone.Success else PillTone.Neutral,
)
}
}
}
}
}
}

View File

@@ -0,0 +1,163 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.annotation.StringRes
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.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.repository.PlayerRustRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The player's own Rust account (PLAN.md §9 M15; `docs/modules/rust/PLAN.md`
* §19, §20, §22): link a Steam account with the code `/link` hands out in game,
* release one, and see what the site has given them on which servers.
*
* **Two reads, one screen, and neither blocks the other.** The entitlement read
* carries its own state because an entitlement is authored against the *website*
* account: it exists before a Steam id does, and the person who has just been
* given something and has not linked yet is exactly the one who needs to see
* both halves at once. A failure on either side leaves the other on screen.
*
* **A refusal is chosen by status, not by prose** (the convention
* [com.runicgateway.app.ui.player.CharactersViewModel] set one game along). The
* four the website distinguishes are four different pieces of advice, and
* flattening them is the failure worth naming: a player told to get a new code
* when the server their code came from was merely unreachable will go and get
* another code from the same unreachable server.
*/
@HiltViewModel
class RustAccountViewModel @Inject constructor(
private val repository: PlayerRustRepository,
) : ViewModel() {
/** 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 State(
val links: UiState<List<RustLinkDto>> = UiState.Loading,
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
/** The Steam id currently being released, so only its own row shows it. */
val unlinking: String? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(links = UiState.Loading, held = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(links = repository.links().toUiState()) }
}
viewModelScope.launch {
_state.update { it.copy(held = repository.permissions().toUiState()) }
}
}
/** Re-read what the site holds without blanking the accounts above it. */
private fun reloadHeld() {
viewModelScope.launch {
_state.update { it.copy(held = repository.permissions().toUiState()) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun link(code: String) {
if (_state.value.busy || code.isBlank()) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = repository.link(code.trim())) {
is ApiResult.Ok -> {
// `already` is a second press of the button, not an error:
// the code was good and that account was already theirs.
val res =
if (result.data.already) R.string.rust_link_already else R.string.rust_link_ok
_state.update { it.copy(busy = false, feedback = Feedback(true, res)) }
refreshAfterChange()
}
is ApiResult.HttpError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
}
is ApiResult.NetworkError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
}
}
}
}
fun unlink(steamId: String) {
if (_state.value.unlinking != null) return
_state.update { it.copy(unlinking = steamId, feedback = null) }
viewModelScope.launch {
val result = repository.unlink(steamId)
_state.update { it.copy(unlinking = null) }
when (result) {
is ApiResult.Ok -> refreshAfterChange()
is ApiResult.HttpError -> _state.update {
it.copy(feedback = Feedback(false, R.string.rust_unlink_error))
}
is ApiResult.NetworkError -> _state.update {
it.copy(feedback = Feedback(false, R.string.error_network))
}
}
}
}
/**
* Both halves, after the caller changed one of them.
*
* Linking an account does not change what the site has authored — but it
* changes what reaches a game, and the next sync is what makes that true. So
* the entitlement list is re-read too: its `live` marks are the only thing on
* this screen that a link can silently invalidate.
*/
private fun refreshAfterChange() {
viewModelScope.launch {
_state.update { it.copy(links = repository.links().toUiState()) }
}
reloadHeld()
}
private fun linkErrorRes(status: Int): Int = when (status) {
// Unknown or expired: the code is spent, and the way out is a new one.
400 -> R.string.rust_link_bad_code
// Another website account holds that Steam id. It is never moved
// silently; `/unlink` in game is the release (D23).
409 -> R.string.rust_link_taken
429 -> R.string.rust_link_capped
// A server could not be reached. **The code is still good**, which is
// why this may not say "get a new one".
503 -> R.string.rust_link_unreachable
else -> R.string.rust_link_error
}
}

View File

@@ -605,4 +605,29 @@
<string name="rust_col_structures">Built</string>
<string name="rust_col_played">Played</string>
<string name="rust_online_badge">%1$d players online</string>
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
<string name="menu_rust_account">My Rust account</string>
<string name="rust_link_title">Link your Steam account</string>
<string name="rust_link_hint">Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once.</string>
<string name="rust_link_code">Link code</string>
<string name="rust_link_action">Link account</string>
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
<string name="rust_link_already">That account was already linked to you.</string>
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
<string name="rust_link_error">Could not link that code.</string>
<string name="rust_unlink_action">Unlink</string>
<string name="rust_unlink_error">Could not unlink that account.</string>
<string name="rust_account_none">No Steam account is linked to this profile yet.</string>
<string name="rust_account_fleet_note">A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game.</string>
<string name="rust_held_title">What you can do in game</string>
<string name="rust_held_empty">Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.</string>
<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_reach_live">%1$s · has it</string>
<string name="rust_reach_waiting">%1$s · waiting</string>
</resources>