From 4878b74e091e90153c8241e8aebbf337f2ab17e8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 20:19:13 -0500 Subject: [PATCH] =?UTF-8?q?feat(rust):=20a=20player's=20own=20Rust=20accou?= =?UTF-8?q?nt=20on=20the=20phone=20=E2=80=94=20M15=20(module-rust=20phase?= =?UTF-8?q?=208,=20Android=20leg=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- .../app/data/api/PlayerRustApi.kt | 75 +++++ .../app/data/api/dto/PlayerRustDto.kt | 127 ++++++++ .../data/repository/PlayerRustRepository.kt | 51 +++ .../com/runicgateway/app/di/NetworkModule.kt | 10 + .../java/com/runicgateway/app/ui/RunicApp.kt | 9 + .../runicgateway/app/ui/navigation/Menu.kt | 16 + .../runicgateway/app/ui/navigation/Routes.kt | 11 + .../app/ui/rust/RustAccountScreen.kt | 293 ++++++++++++++++++ .../app/ui/rust/RustAccountViewModel.kt | 163 ++++++++++ app/src/main/res/values/strings.xml | 25 ++ .../app/data/api/fake/FakePlayerRustApi.kt | 70 +++++ .../ui/navigation/MenuCapabilityGatingTest.kt | 22 +- .../app/ui/navigation/RustNavigationTest.kt | 39 +++ .../app/ui/rust/RustAccountViewModelTest.kt | 205 ++++++++++++ 14 files changed, 1112 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt new file mode 100644 index 0000000..7ae5f89 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt @@ -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 `//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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt new file mode 100644 index 0000000..ddd822c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt @@ -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 = 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 = emptyList(), + val grants: List = 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 = emptyList(), + val reach: List = 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 = 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt new file mode 100644 index 0000000..29f696a --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt @@ -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> = + safeApiCall { api.links() }.map { it.links } + + /** Redeem a code from `/link` in game. */ + suspend fun link(code: String): ApiResult = + safeApiCall { api.link(RustLinkRequest(code)) } + + /** Release one of the caller's own links. */ + suspend fun unlink(steamId: String): ApiResult = + safeApiCall { api.unlink(steamId) }.map { it.unlinked } + + /** Ranks and grants the site holds for the caller, resolved per server. */ + suspend fun permissions(): ApiResult = + safeApiCall { api.permissions() } +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 8793f52..ed3e7df 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -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 diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index c4f221e..4ffa73e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -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 diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index cdf8ff5..a8f7c8d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -177,6 +177,22 @@ val APP_MENU: List = 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), diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index e7308f1..2654f47 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -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" diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt new file mode 100644 index 0000000..0ff2b32 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt @@ -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) { + 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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt new file mode 100644 index 0000000..ce0d90b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt @@ -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> = UiState.Loading, + val held: UiState = 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.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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2ca493..0ca42ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -605,4 +605,29 @@ Built Played %1$d players online + + + My Rust account + Link your Steam account + 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. + Link code + Link account + Linked. Your play on our servers now appears under your name here. + That account was already linked to you. + That code is unknown or has expired. Type /link in game for a new one. + That Steam account is linked to another website account. Run /unlink in game to release it. + Too many attempts just now. Try again in a few minutes. + A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute. + Could not link that code. + Unlink + Could not unlink that account. + No Steam account is linked to this profile yet. + A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game. + What you can do in game + Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover. + None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync. + A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back. + No servers are configured yet. + %1$s · has it + %1$s · waiting diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt new file mode 100644 index 0000000..d245d0e --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt @@ -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 + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt index aa1e757..99df01a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt @@ -123,13 +123,27 @@ class MenuCapabilityGatingTest { // rather than by the visibility framework. They rendered on a backend with // no module installed and answered "This content couldn't be found", // 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 { it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS } - assertEquals(8, onAModulePath.size) - assertTrue( - onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(), - onAModulePath.all { it.capability == Capability.SHARD }, + assertEquals(9, onAModulePath.size) + + val expected = onAModulePath.associate { entry -> + entry.route to if (entry.route.startsWith("player/rust")) { + Capability.RUST + } else { + Capability.SHARD + } + } + + assertEquals( + expected, + onAModulePath.associate { it.route to it.capability }, ) } diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt index cce4a9b..30e7a37 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt @@ -3,7 +3,9 @@ */ 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.SessionUser import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.data.repository.SiteCapabilities import org.junit.Assert.assertEquals @@ -102,6 +104,43 @@ class RustNavigationTest { 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() { // 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 diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt new file mode 100644 index 0000000..9a8655e --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt @@ -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) + } +}