Merge pull request 'feat(rust): a player's own Rust account on the phone — M15 (module-rust phase 8, Android leg B)' (#48) from feat/rust-p8-android-b into edge
Reviewed-on: #48
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import com.runicgateway.app.data.api.EventsApi
|
|||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.AdminApi
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
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.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
import com.runicgateway.app.data.api.RustApi
|
import com.runicgateway.app.data.api.RustApi
|
||||||
@@ -146,6 +147,15 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
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. */
|
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
|
|||||||
import com.runicgateway.app.ui.shard.RulesScreen
|
import com.runicgateway.app.ui.shard.RulesScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardBoard
|
import com.runicgateway.app.ui.shard.ShardBoard
|
||||||
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
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.RustServerScreen
|
||||||
import com.runicgateway.app.ui.rust.RustServersScreen
|
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardScreen
|
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".
|
// screen and is deliberately absent — a back gesture there means "back".
|
||||||
Routes.RUST,
|
Routes.RUST,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
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,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -739,6 +742,12 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.PLAYER_HOUSES) {
|
composable(Routes.PLAYER_HOUSES) {
|
||||||
PlayerGate(session, navController) { MyHousesScreen() }
|
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.
|
// ── 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
|
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuAccess.PLAYER,
|
MenuAccess.PLAYER,
|
||||||
capability = Capability.SHARD,
|
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.
|
// 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_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ object Routes {
|
|||||||
const val RUST = "rust"
|
const val RUST = "rust"
|
||||||
const val RUST_SERVER = "rust/servers/{serverId}"
|
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). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// When, and which server minted the code. Not part of the
|
||||||
|
// identity — a link is fleet-wide — but it is where a support
|
||||||
|
// conversation starts, and the website's own row says it.
|
||||||
|
val when_ = rustAgo(link.linkedAt)
|
||||||
|
val where = link.serverId?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
if (when_ != null || where != null) {
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(
|
||||||
|
when_?.let { stringResource(R.string.rust_account_linked_when, it) },
|
||||||
|
where,
|
||||||
|
).joinToString(" · "),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -605,4 +605,30 @@
|
|||||||
<string name="rust_col_structures">Built</string>
|
<string name="rust_col_structures">Built</string>
|
||||||
<string name="rust_col_played">Played</string>
|
<string name="rust_col_played">Played</string>
|
||||||
<string name="rust_online_badge">%1$d players online</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_linked_when">linked %1$s</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>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.fake
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.PlayerRustApi
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configurable fake of [PlayerRustApi] (M15).
|
||||||
|
*
|
||||||
|
* [linkError] is its own field rather than a shared [error]: the tests that
|
||||||
|
* matter here are about a **redemption** that fails while the reads around it
|
||||||
|
* succeed — a refused code must leave the accounts and entitlements on screen,
|
||||||
|
* and one `error` for the whole interface could not express that.
|
||||||
|
*/
|
||||||
|
class FakePlayerRustApi : PlayerRustApi {
|
||||||
|
|
||||||
|
/** Thrown by every call — the "the site is down" case. */
|
||||||
|
var error: Throwable? = null
|
||||||
|
|
||||||
|
/** Thrown by one call each, so one half of the screen can fail alone. */
|
||||||
|
var linksError: Throwable? = null
|
||||||
|
var linkError: Throwable? = null
|
||||||
|
var unlinkError: Throwable? = null
|
||||||
|
var permissionsError: Throwable? = null
|
||||||
|
|
||||||
|
var links: RustLinkListDto = RustLinkListDto()
|
||||||
|
var linkResult: RustLinkResultDto = RustLinkResultDto(linked = true)
|
||||||
|
var permissions: RustPlayerPermissionsDto = RustPlayerPermissionsDto()
|
||||||
|
|
||||||
|
var linksCalls: Int = 0
|
||||||
|
var permissionsCalls: Int = 0
|
||||||
|
|
||||||
|
/** The code the last redemption carried, exactly as the screen sent it. */
|
||||||
|
var lastCode: String? = null
|
||||||
|
|
||||||
|
/** The Steam id the last release named. */
|
||||||
|
var lastUnlinked: String? = null
|
||||||
|
|
||||||
|
override suspend fun links(): RustLinkListDto {
|
||||||
|
linksCalls++
|
||||||
|
linksError?.let { throw it }
|
||||||
|
error?.let { throw it }
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun link(body: RustLinkRequest): RustLinkResultDto {
|
||||||
|
lastCode = body.code
|
||||||
|
linkError?.let { throw it }
|
||||||
|
return linkResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unlink(steamId: String): RustUnlinkResultDto {
|
||||||
|
lastUnlinked = steamId
|
||||||
|
unlinkError?.let { throw it }
|
||||||
|
return RustUnlinkResultDto(unlinked = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun permissions(): RustPlayerPermissionsDto {
|
||||||
|
permissionsCalls++
|
||||||
|
permissionsError?.let { throw it }
|
||||||
|
error?.let { throw it }
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,13 +123,27 @@ class MenuCapabilityGatingTest {
|
|||||||
// rather than by the visibility framework. They rendered on a backend with
|
// rather than by the visibility framework. They rendered on a backend with
|
||||||
// no module installed and answered "This content couldn't be found",
|
// no module installed and answered "This content couldn't be found",
|
||||||
// through a green suite.
|
// through a green suite.
|
||||||
|
//
|
||||||
|
// **It asks which module, not merely whether one.** M15 put a second
|
||||||
|
// game's self-service row under `player/`, and a row that declared the
|
||||||
|
// wrong module's capability would render on a site running the other
|
||||||
|
// game and answer 404 — the same failure in a new place.
|
||||||
val onAModulePath = APP_MENU.filter {
|
val onAModulePath = APP_MENU.filter {
|
||||||
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
||||||
}
|
}
|
||||||
assertEquals(8, onAModulePath.size)
|
assertEquals(9, onAModulePath.size)
|
||||||
assertTrue(
|
|
||||||
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(),
|
val expected = onAModulePath.associate { entry ->
|
||||||
onAModulePath.all { it.capability == Capability.SHARD },
|
entry.route to if (entry.route.startsWith("player/rust")) {
|
||||||
|
Capability.RUST
|
||||||
|
} else {
|
||||||
|
Capability.SHARD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expected,
|
||||||
|
onAModulePath.associate { it.route to it.capability },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.Role
|
||||||
import com.runicgateway.app.core.auth.Session
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionUser
|
||||||
import com.runicgateway.app.data.repository.Capability
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -102,6 +104,43 @@ class RustNavigationTest {
|
|||||||
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
|
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The player's own row (M15) ────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun player() = signedIn(Role.PLAYER)
|
||||||
|
|
||||||
|
private fun staff() = signedIn(Role.ADMIN)
|
||||||
|
|
||||||
|
private fun signedIn(role: Role) =
|
||||||
|
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||||
|
|
||||||
|
private fun routesFor(session: Session, capabilities: SiteCapabilities?) =
|
||||||
|
visibleEntries(APP_MENU, session, features = null, capabilities = capabilities)
|
||||||
|
.map { it.route }
|
||||||
|
|
||||||
|
@Test fun theAccountRowNeedsBothASessionAndTheModule() {
|
||||||
|
// Signed out, the row is not there whatever the backend serves: every
|
||||||
|
// route behind it is `requireAuth`.
|
||||||
|
assertFalse(Routes.PLAYER_RUST in routesFor(serving(Capability.RUST)))
|
||||||
|
|
||||||
|
assertTrue(Routes.PLAYER_RUST in routesFor(player(), serving(Capability.RUST)))
|
||||||
|
assertFalse(
|
||||||
|
"a UO-only site has no Rust account row",
|
||||||
|
Routes.PLAYER_RUST in routesFor(player(), serving(Capability.SHARD)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun staffSeeTheAccountRowToo() {
|
||||||
|
// `PLAYER` access is `isPlayer || isStaff` — staff play the game as well,
|
||||||
|
// and `/player/rust/*` is `requireAuth` with no role above it.
|
||||||
|
assertTrue(Routes.PLAYER_RUST in routesFor(staff(), serving(Capability.RUST)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theAccountRowHangsOnTheModuleNotOnASurfaceWord() {
|
||||||
|
// `identity` is one of the module's surface words, like `servers` above.
|
||||||
|
// A capability answers *is the module there*, and only `rust` does.
|
||||||
|
assertFalse(Routes.PLAYER_RUST in routesFor(player(), serving("identity")))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun aRustPathWithAQueryHandsOff() {
|
@Test fun aRustPathWithAQueryHandsOff() {
|
||||||
// The website keeps tab, filter, wipe and sort in the URL; the app keeps
|
// The website keeps tab, filter, wipe and sort in the URL; the app keeps
|
||||||
// them in a view model. Resolving `?tab=wipes` natively would silently drop
|
// them in a view model. Resolving `?tab=wipes` natively would silently drop
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerGrantDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustReachDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakePlayerRustApi
|
||||||
|
import com.runicgateway.app.data.repository.PlayerRustRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.httpError
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own Rust account (M15).
|
||||||
|
*
|
||||||
|
* The properties worth a test are the ones a screenshot cannot show: that the
|
||||||
|
* four refusals stay four different pieces of advice, that a failure on one half
|
||||||
|
* of the screen leaves the other half standing, and that a link re-reads what the
|
||||||
|
* site holds — because linking an account is the one action here that changes
|
||||||
|
* what reaches a game without changing anything the site authored.
|
||||||
|
*/
|
||||||
|
class RustAccountViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val dispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val api = FakePlayerRustApi()
|
||||||
|
private val repository = PlayerRustRepository(api)
|
||||||
|
|
||||||
|
private fun viewModel() = RustAccountViewModel(repository)
|
||||||
|
|
||||||
|
private fun linked(vararg ids: String) =
|
||||||
|
RustLinkListDto(ids.map { RustLinkDto(steamId = it, name = "Wanderer") })
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it reads both halves on open`() {
|
||||||
|
api.links = linked("7656119")
|
||||||
|
api.permissions = RustPlayerPermissionsDto(accounts = 1)
|
||||||
|
|
||||||
|
val state = viewModel().state.value
|
||||||
|
|
||||||
|
assertEquals(1, api.linksCalls)
|
||||||
|
assertEquals(1, api.permissionsCalls)
|
||||||
|
assertTrue(state.links is UiState.Success)
|
||||||
|
assertTrue(state.held is UiState.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `entitlements still render when the account list fails, and the other way round`() {
|
||||||
|
api.linksError = httpError(500)
|
||||||
|
api.permissions = RustPlayerPermissionsDto(
|
||||||
|
accounts = 0,
|
||||||
|
grants = listOf(RustPlayerGrantDto(permission = "kits.vip")),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only the links read throws; the permission read answers. An entitlement exists before a Steam id does, so a
|
||||||
|
// failed account read must not take it off the screen.
|
||||||
|
val state = viewModel().state.value
|
||||||
|
|
||||||
|
assertTrue(state.links is UiState.Error)
|
||||||
|
assertTrue(state.held is UiState.Success)
|
||||||
|
assertEquals(1, (state.held as UiState.Success).data.grants.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a refused code is four different pieces of advice, never one`() {
|
||||||
|
val cases = mapOf(
|
||||||
|
400 to R.string.rust_link_bad_code,
|
||||||
|
409 to R.string.rust_link_taken,
|
||||||
|
429 to R.string.rust_link_capped,
|
||||||
|
// The code is STILL GOOD here. A player told to get a new one would
|
||||||
|
// go back to the same unreachable server for it.
|
||||||
|
503 to R.string.rust_link_unreachable,
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((status, expected) in cases) {
|
||||||
|
val vm = viewModel()
|
||||||
|
api.linkError = httpError(status)
|
||||||
|
|
||||||
|
vm.link("K7M2PQ")
|
||||||
|
|
||||||
|
assertEquals("status $status", expected, vm.state.value.feedback?.messageRes)
|
||||||
|
assertFalse(vm.state.value.feedback!!.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dead network is not a refused code`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
api.linkError = IOException("down")
|
||||||
|
|
||||||
|
vm.link("K7M2PQ")
|
||||||
|
|
||||||
|
assertEquals(R.string.error_network, vm.state.value.feedback?.messageRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `linking again with an account already held is a success, not an error`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
api.linkResult = RustLinkResultDto(linked = true, already = true)
|
||||||
|
|
||||||
|
vm.link("K7M2PQ")
|
||||||
|
|
||||||
|
assertEquals(R.string.rust_link_already, vm.state.value.feedback?.messageRes)
|
||||||
|
assertTrue(vm.state.value.feedback!!.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a successful link re-reads what the site holds, not only the accounts`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
val linksBefore = api.linksCalls
|
||||||
|
val permissionsBefore = api.permissionsCalls
|
||||||
|
|
||||||
|
vm.link("K7M2PQ")
|
||||||
|
|
||||||
|
assertEquals(linksBefore + 1, api.linksCalls)
|
||||||
|
assertEquals(
|
||||||
|
"a link changes what REACHES a game; the live marks are the only thing here it invalidates",
|
||||||
|
permissionsBefore + 1,
|
||||||
|
api.permissionsCalls,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the code is trimmed and sent as typed`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
vm.link(" k7m2pq ")
|
||||||
|
|
||||||
|
assertEquals("k7m2pq", api.lastCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a blank code asks nothing at all`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
vm.link(" ")
|
||||||
|
|
||||||
|
assertEquals(null, api.lastCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `releasing a link names that account and re-reads both halves`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
val linksBefore = api.linksCalls
|
||||||
|
val permissionsBefore = api.permissionsCalls
|
||||||
|
|
||||||
|
vm.unlink("7656119")
|
||||||
|
|
||||||
|
assertEquals("7656119", api.lastUnlinked)
|
||||||
|
assertEquals(linksBefore + 1, api.linksCalls)
|
||||||
|
assertEquals(permissionsBefore + 1, api.permissionsCalls)
|
||||||
|
assertEquals(null, vm.state.value.unlinking)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed release says so and leaves the row alone`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
api.unlinkError = httpError(404)
|
||||||
|
|
||||||
|
vm.unlink("7656119")
|
||||||
|
|
||||||
|
assertEquals(R.string.rust_unlink_error, vm.state.value.feedback?.messageRes)
|
||||||
|
assertEquals(null, vm.state.value.unlinking)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the servers an entitlement reaches arrive resolved, marks and all`() {
|
||||||
|
// The app does no scope arithmetic: `*` never reaches a screen. What
|
||||||
|
// arrives is a list of servers already marked, and this asserts the app
|
||||||
|
// keeps it that way rather than deriving anything of its own.
|
||||||
|
api.permissions = RustPlayerPermissionsDto(
|
||||||
|
accounts = 1,
|
||||||
|
grants = listOf(
|
||||||
|
RustPlayerGrantDto(
|
||||||
|
permission = "kits.vip",
|
||||||
|
scope = "*",
|
||||||
|
reach = listOf(
|
||||||
|
RustReachDto(id = "main", name = "Main", live = true),
|
||||||
|
RustReachDto(id = "creative", name = "Creative", live = false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val held = (viewModel().state.value.held as UiState.Success).data
|
||||||
|
|
||||||
|
assertNotNull(held.grants.first().reach.first { it.id == "main" })
|
||||||
|
assertTrue(held.grants.first().reach.first { it.id == "main" }.live)
|
||||||
|
assertFalse(held.grants.first().reach.first { it.id == "creative" }.live)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user