Compare commits
8 Commits
a6677d5bf9
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3c62d966 | |||
| f695eb6c45 | |||
| 59d955a11d | |||
| a079bd481e | |||
| 4878b74e09 | |||
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 |
@@ -45,8 +45,15 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# `packages: ''` is load-bearing, not tidying. The action's own default is
|
||||
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
|
||||
# default makes `sdkmanager tools` exit 1 and the step fails before a line
|
||||
# of this repo is compiled. It is redundant here regardless: the next step
|
||||
# installs exactly what the build targets.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
# Install exactly what the build targets so it never depends on AGP's
|
||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -70,10 +70,20 @@ data class RustServerDto(
|
||||
val stale: Boolean = false,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */
|
||||
/**
|
||||
* `GET /public/rust/servers/{id}/events` — the killfeed and everything else public.
|
||||
*
|
||||
* **Nothing names who is online by default** (org lead, 2026-09-22). Below the
|
||||
* operator's presence audience — staff unless widened — the server withholds
|
||||
* every item that says a named player was on (joins, deaths, chat, tallies) and
|
||||
* says so with [presenceHidden]; [presenceAudience] is who CAN see them. The
|
||||
* screen says it, so a thin feed reads as withheld rather than as a quiet server.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEventListDto(
|
||||
val events: List<RustEventDto> = emptyList(),
|
||||
val presenceHidden: Boolean = false,
|
||||
val presenceAudience: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -171,10 +181,20 @@ data class RustWipeDto(
|
||||
val lastSeen: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/online` — who is on right now. */
|
||||
/**
|
||||
* `GET /public/rust/servers/{id}/online` — who is on right now.
|
||||
*
|
||||
* Below the operator's presence audience the names are withheld: [hidden] is
|
||||
* true, [players] is empty and [count] is still the real number — a count names
|
||||
* nobody, and it is already on the server line. An empty list with [hidden] set
|
||||
* must never render as "nobody is on".
|
||||
*/
|
||||
@Serializable
|
||||
data class RustOnlineDto(
|
||||
val players: List<RustPresenceDto> = emptyList(),
|
||||
val hidden: Boolean = false,
|
||||
val count: Int = 0,
|
||||
val audience: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -7,9 +7,9 @@ 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.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import javax.inject.Inject
|
||||
@@ -50,14 +50,14 @@ class RustRepository @Inject constructor(
|
||||
kinds: List<String> = emptyList(),
|
||||
wipe: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustEventDto>> = safeApiCall {
|
||||
): ApiResult<RustEventListDto> = safeApiCall {
|
||||
api.getEvents(
|
||||
id = id,
|
||||
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.events }
|
||||
}
|
||||
|
||||
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||
suspend fun leaderboard(
|
||||
@@ -78,7 +78,12 @@ class RustRepository @Inject constructor(
|
||||
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||
|
||||
/** The presence board. Rows survive an unreachable server, by design. */
|
||||
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
|
||||
safeApiCall { api.getOnline(id) }.map { it.players }
|
||||
/**
|
||||
* The presence board. Rows survive an unreachable server, by design.
|
||||
*
|
||||
* Answered whole rather than as its rows: `hidden` and `count` are what let
|
||||
* the screen tell "withheld from you" from "nobody is on".
|
||||
*/
|
||||
suspend fun online(id: String): ApiResult<RustOnlineDto> =
|
||||
safeApiCall { api.getOnline(id) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,6 +177,22 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuAccess.PLAYER,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// The player's own Rust account (M15). Beside the three UO rows above and
|
||||
// gated exactly as they are: the module's `rust` capability (a row needs the
|
||||
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
|
||||
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
|
||||
//
|
||||
// **`rust`, not `identity`.** The module declares a surface word for each of
|
||||
// its features, and D16's rule is that a capability answers one question —
|
||||
// *is the module there* — so a surface word is not what a row hangs on. The
|
||||
// paths these screens call are knowledge the app has from reading the
|
||||
// module's router, exactly as §2.9 requires.
|
||||
MenuEntry(
|
||||
Routes.PLAYER_RUST,
|
||||
R.string.menu_rust_account,
|
||||
MenuAccess.PLAYER,
|
||||
capability = Capability.RUST,
|
||||
),
|
||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -33,8 +34,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
@@ -252,7 +254,7 @@ private fun WipeFilter(
|
||||
|
||||
@Composable
|
||||
private fun FeedPanel(
|
||||
feed: Polled<List<RustEventDto>>,
|
||||
feed: Polled<RustEventListDto>,
|
||||
filterId: String,
|
||||
onFilter: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -275,7 +277,25 @@ private fun FeedPanel(
|
||||
when (val s = feed.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
is UiState.Success -> {
|
||||
// Said once, above the rows, so a thin feed reads as withheld
|
||||
// rather than as a quiet server (org lead: nothing names who is
|
||||
// online by default).
|
||||
if (s.data.presenceHidden) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (s.data.presenceAudience == "signed_in") {
|
||||
R.string.rust_feed_presence_hidden_signin
|
||||
} else {
|
||||
R.string.rust_feed_presence_hidden_staff
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (s.data.events.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
@@ -285,7 +305,8 @@ private fun FeedPanel(
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.id }) { FeedRow(it) }
|
||||
items(s.data.events, key = { it.id }) { FeedRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,14 +465,29 @@ private fun LeaderboardPanel(
|
||||
|
||||
@Composable
|
||||
private fun OnlinePanel(
|
||||
online: Polled<List<RustPresenceDto>>,
|
||||
online: Polled<RustOnlineDto>,
|
||||
serverOnline: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (val s = online.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
// Withheld is not empty. Below the operator's audience the server sends
|
||||
// the count and no names, and an empty list rendered as "nobody is on"
|
||||
// would be a false statement about a full server.
|
||||
is UiState.Success -> if (s.data.hidden) {
|
||||
val count = s.data.count
|
||||
EmptyView(
|
||||
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
|
||||
stringResource(
|
||||
when (s.data.audience) {
|
||||
"signed_in" -> R.string.rust_online_hidden_signin
|
||||
"public" -> R.string.rust_online_hidden_public
|
||||
else -> R.string.rust_online_hidden_staff
|
||||
},
|
||||
),
|
||||
)
|
||||
} else if (s.data.players.isEmpty()) {
|
||||
EmptyView(
|
||||
stringResource(
|
||||
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||
@@ -479,7 +515,7 @@ private fun OnlinePanel(
|
||||
if (online.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.steamId }) { player ->
|
||||
items(s.data.players, key = { it.steamId }) { player ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
|
||||
@@ -6,9 +6,9 @@ package com.runicgateway.app.ui.rust
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
@@ -52,8 +52,10 @@ data class RustServerUi(
|
||||
val sort: String = RustSort.KILLS,
|
||||
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||
val selectedWipe: String? = null,
|
||||
val feed: Polled<List<RustEventDto>> = Polled(),
|
||||
val online: Polled<List<RustPresenceDto>> = Polled(),
|
||||
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
|
||||
val feed: Polled<RustEventListDto> = Polled(),
|
||||
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
|
||||
val online: Polled<RustOnlineDto> = Polled(),
|
||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
||||
)
|
||||
|
||||
@@ -593,6 +593,17 @@
|
||||
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
|
||||
operator's audience the server sends a count and no names. -->
|
||||
<plurals name="rust_online_hidden_count">
|
||||
<item quantity="one">%1$d player online</item>
|
||||
<item quantity="other">%1$d players online</item>
|
||||
</plurals>
|
||||
<string name="rust_online_hidden_staff">Only this site’s staff can see who they are.</string>
|
||||
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
|
||||
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
|
||||
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this site’s staff can see what players did.</string>
|
||||
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
|
||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||
<string name="rust_presence_live">On the server right now.</string>
|
||||
@@ -605,4 +616,30 @@
|
||||
<string name="rust_col_structures">Built</string>
|
||||
<string name="rust_col_played">Played</string>
|
||||
<string name="rust_online_badge">%1$d players online</string>
|
||||
|
||||
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
|
||||
<string name="menu_rust_account">My Rust account</string>
|
||||
<string name="rust_link_title">Link your Steam account</string>
|
||||
<string name="rust_link_hint">Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once.</string>
|
||||
<string name="rust_link_code">Link code</string>
|
||||
<string name="rust_link_action">Link account</string>
|
||||
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
|
||||
<string name="rust_link_already">That account was already linked to you.</string>
|
||||
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
|
||||
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
|
||||
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
|
||||
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
|
||||
<string name="rust_link_error">Could not link that code.</string>
|
||||
<string name="rust_unlink_action">Unlink</string>
|
||||
<string name="rust_unlink_error">Could not unlink that account.</string>
|
||||
<string name="rust_account_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>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Nothing names who is online by default (org lead, 2026-09-22). These are the
|
||||
* answers module-rust's public routes give below and inside the operator's
|
||||
* presence audience, copied from a live walk against the module — and the old
|
||||
* shape, from a core running a module that predates the flag.
|
||||
*/
|
||||
class RustPresenceDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun withheldOnlineCarriesTheCountAndNoNames() {
|
||||
val dto = json.decodeFromString<RustOnlineDto>(
|
||||
"""{"players":[],"hidden":true,"count":2,"audience":"staff"}""",
|
||||
)
|
||||
assertTrue(dto.hidden)
|
||||
assertEquals(2, dto.count)
|
||||
assertEquals("staff", dto.audience)
|
||||
assertTrue(dto.players.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun visibleOnlineNamesThePlayers() {
|
||||
val dto = json.decodeFromString<RustOnlineDto>(
|
||||
"""{"players":[{"steamId":"76561198000000002","name":"Builder Bea","sleeping":false,
|
||||
"connectedAt":"2026-09-23T05:02:30.000Z"}],"hidden":false,"count":1,"audience":"staff"}""",
|
||||
)
|
||||
assertFalse(dto.hidden)
|
||||
assertEquals("Builder Bea", dto.players.single().name)
|
||||
}
|
||||
|
||||
@Test fun anOlderModuleWithoutTheFlagReadsAsVisible() {
|
||||
val dto = json.decodeFromString<RustOnlineDto>("""{"players":[]}""")
|
||||
assertFalse(dto.hidden)
|
||||
assertNull(dto.audience)
|
||||
}
|
||||
|
||||
@Test fun aWithheldFeedSaysSo() {
|
||||
val dto = json.decodeFromString<RustEventListDto>(
|
||||
"""{"events":[{"id":11,"kind":"server.wipe","t":1789500000000,"wipeId":"w-20260920T000000Z",
|
||||
"steamId":null,"frame":{}}],"presenceHidden":true,"presenceAudience":"signed_in"}""",
|
||||
)
|
||||
assertTrue(dto.presenceHidden)
|
||||
assertEquals("signed_in", dto.presenceAudience)
|
||||
assertEquals("server.wipe", dto.events.single().kind)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
@@ -100,6 +101,35 @@ class RustServerViewModelTest {
|
||||
assertEquals(onlineBefore + 1, api.onlineCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withheld names arrive as withheld, with the count, never as an empty list`() {
|
||||
// Nothing names who is online by default (org lead, 2026-09-22). The
|
||||
// screen's whole job with this answer is to say "12 online" rather than
|
||||
// "nobody is on", so the state must carry `hidden` and `count` intact.
|
||||
api.online = RustOnlineDto(players = emptyList(), hidden = true, count = 12, audience = "staff")
|
||||
val vm = viewModel()
|
||||
|
||||
vm.selectTab(RustTab.ONLINE)
|
||||
|
||||
val state = vm.state.value.online.state
|
||||
assertTrue(state is UiState.Success)
|
||||
val answer = (state as UiState.Success).data
|
||||
assertTrue(answer.hidden)
|
||||
assertEquals(12, answer.count)
|
||||
assertEquals("staff", answer.audience)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a feed with its players withheld says so in the state`() {
|
||||
api.events = RustEventListDto(events = emptyList(), presenceHidden = true, presenceAudience = "signed_in")
|
||||
val vm = viewModel()
|
||||
|
||||
val state = vm.state.value.feed.state
|
||||
assertTrue(state is UiState.Success)
|
||||
assertTrue((state as UiState.Success).data.presenceHidden)
|
||||
assertEquals("signed_in", state.data.presenceAudience)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a poll on a still panel asks for nothing but the server line`() {
|
||||
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))
|
||||
|
||||
Reference in New Issue
Block a user