diff --git a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt new file mode 100644 index 0000000..cdd9c92 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt @@ -0,0 +1,94 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api + +import com.runicgateway.app.data.api.dto.RustEventListDto +import com.runicgateway.app.data.api.dto.RustLeaderboardDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.dto.RustServerResponse +import com.runicgateway.app.data.api.dto.RustWipeListDto +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` + * §17). + * + * **These paths are hardcoded, and that is the contract rather than a shortcut.** + * `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so + * the app cannot build `//servers` from what `GET /public/modules` + * reports. A capability answers one question — *is the module there* — and these + * five addresses are knowledge the app has because someone read the module's + * router, exactly as the nine `/uo/` paths in [NavPaths] are. + * + * Its own interface, not a section of [PublicApi], for the reason [EventsApi] is + * its own: these exist only where the Rust module is installed, and a backend + * running a different game answers none of them. + */ +interface RustApi { + + /** + * Every Rust server this site follows. + * + * Answers from the module's own tables and never from a live call to a game + * host, so it succeeds while every server in the fleet is off — a server + * nobody can reach comes back `online: false, stale: true` with everything it + * last said still attached. There is no failure case here for the game being + * down, only for the website being down. + */ + @GET("api/v1/public/rust/servers") + suspend fun getServers(): RustServerListDto + + /** + * One server, or a **404**. + * + * The only route under `/servers/{id}` that can say a server is not there: + * the four below answer an empty list for an id nobody configured, because an + * unknown server genuinely has no events and nobody online. A server an + * operator **disabled** answers the same 404 — switching one off is not + * switching it into a refusal. + */ + @GET("api/v1/public/rust/servers/{id}") + suspend fun getServer(@Path("id") id: String): RustServerResponse + + /** + * The feed, newest first. + * + * [kind] is comma-separated and [wipe] a wipe id; both are optional, and an + * **absent one must be absent rather than empty** — `?wipe=` asks for a wipe + * whose id is the empty string and answers nothing, with no error to notice. + * Retrofit drops a null `@Query` entirely, which is why these are nullable + * and never defaulted to `""`. + * + * The server serves a default-deny allowlist: moderation events, login + * attempts and anything carrying an IP address are stored and never returned + * here, whatever is asked for. + */ + @GET("api/v1/public/rust/servers/{id}/events") + suspend fun getEvents( + @Path("id") id: String, + @Query("kind") kind: String? = null, + @Query("wipe") wipe: String? = null, + @Query("limit") limit: Int? = null, + ): RustEventListDto + + /** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */ + @GET("api/v1/public/rust/servers/{id}/leaderboard") + suspend fun getLeaderboard( + @Path("id") id: String, + @Query("wipe") wipe: String? = null, + @Query("sort") sort: String? = null, + @Query("limit") limit: Int? = null, + ): RustLeaderboardDto + + /** Every wipe this server has had, newest first. */ + @GET("api/v1/public/rust/servers/{id}/wipes") + suspend fun getWipes(@Path("id") id: String): RustWipeListDto + + /** The presence board, which an unreachable server does not clear. */ + @GET("api/v1/public/rust/servers/{id}/online") + suspend fun getOnline(@Path("id") id: String): RustOnlineDto +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt new file mode 100644 index 0000000..4e5a690 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -0,0 +1,196 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17, + * §18; M14). + * + * **Every one of these renders while the game is off**, which is the module's own + * promise and therefore this leg's: the website never calls a game server from a + * page, it answers from its own tables, and a server nobody can reach answers + * `online: false` with everything it last said still attached. Nothing here has + * an "unavailable" shape, because there is no such answer on this wire. + */ + +/** `GET /public/rust/servers` — every server this site follows. */ +@Serializable +data class RustServerListDto( + val servers: List = emptyList(), +) + +/** `GET /public/rust/servers/{id}` — one of them, or a 404. */ +@Serializable +data class RustServerResponse( + val server: RustServerDto = RustServerDto(), +) + +/** + * One Rust server and what it last reported. + * + * **[online] and [stale] are not the same fact and the screen needs both.** + * `online` is what the last frame said; `stale` is whether anything has arrived + * recently enough to believe it. The server computes `online` as *"the row says + * up AND the row is fresh"*, so a stale row can never claim a server is up — but + * `stale` still has to come through, because a fresh row saying "down" and a row + * nobody has written in an hour are different things to say to a reader. + * + * **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is + * not.** The module shipped a defect on exactly this in phase 3 and fixed it in + * phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it + * as "last reported" made an offline server claim it had just checked in, every + * thirty seconds, for as long as it stayed down. Only a frame moves + * `lastSeenAt`. The app must not repeat the mistake one tier along. + */ +@Serializable +data class RustServerDto( + val id: String = "", + val name: String = "", + val online: Boolean = false, + val players: Int = 0, + val maxPlayers: Int = 0, + val hostname: String? = null, + val level: String? = null, + val worldSize: Int? = null, + val seed: Long? = null, + /** The CURRENT wipe, from the state row rather than the newest ingested wipe. */ + val wipeId: String? = null, + val wipedAt: String? = null, + /** When a frame last arrived. What "last reported" means. */ + val lastSeenAt: String? = null, + /** When this module last wrote the row — a failed poll moves it too. */ + val updatedAt: String? = null, + val stale: Boolean = false, +) + +/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */ +@Serializable +data class RustEventListDto( + val events: List = emptyList(), +) + +/** + * One stored frame. + * + * **[frame] is deliberately untyped.** The module stores the whole frame the + * bridge plugin emitted and indexes only the columns it serves, so the fields + * differ per [kind] and a later protocol adds more. A sealed hierarchy here would + * have to be extended in this repo before a server running a newer plugin could + * say anything new, and the module's own rule is the opposite: an unknown kind + * renders as itself rather than being dropped. [RustFeed] is the one place that + * knows the field names. + * + * [t] is epoch milliseconds — the stamp the plugin put on the frame, not a + * database column, so it is a number here and an ISO string everywhere else on + * this wire. + */ +@Serializable +data class RustEventDto( + val id: Long = 0, + val kind: String = "", + val t: Long = 0, + val wipeId: String? = null, + val steamId: String? = null, + val frame: JsonObject = JsonObject(emptyMap()), +) { + /** + * One frame field as text, or null. + * + * **A JSON `null` answers null, not the four letters.** The plugin writes + * explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a + * primitive's `content` is the string `"null"` for every one of them, which + * would put the word into a killfeed line. An empty string answers null too: + * the callers here all mean "is there something to show". + */ + fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() } + + /** One frame field as a number, or null when it is absent, null or not one. */ + fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull() + + /** One frame field as a flag. Absent, null and anything non-boolean are all false. */ + fun flag(key: String): Boolean = primitive(key)?.content == "true" + + /** The raw element, for a caller that wants to decide for itself. */ + fun raw(key: String): JsonElement? = frame[key] + + private fun primitive(key: String): JsonPrimitive? = + (frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull } +} + +/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */ +@Serializable +data class RustLeaderboardDto( + val leaderboard: List = emptyList(), +) + +/** + * One player's standing. + * + * All-time is these same per-wipe rows summed rather than a second set of + * counters, so the two can never disagree — which is why a player who appears + * only in an older wipe **drops out** of the current one rather than reading + * zero. The screen must not fill that gap in with zeroes. + */ +@Serializable +data class RustLeaderboardRowDto( + val steamId: String = "", + val name: String? = null, + val kills: Int = 0, + val deaths: Int = 0, + val npcKills: Int = 0, + val structures: Int = 0, + val playtimeSec: Long = 0, + val lastSeen: String? = null, +) + +/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */ +@Serializable +data class RustWipeListDto( + val wipes: List = emptyList(), +) + +/** + * One wipe. + * + * [wipeId] is derived by the bridge plugin from the save's creation time and + * stamped on every frame, so it is the same id the feed and the leaderboard are + * filtered by — which is what makes the per-wipe view navigable at all. + */ +@Serializable +data class RustWipeDto( + val wipeId: String = "", + val saveCreatedAt: String? = null, + val firstSeen: String? = null, + val lastSeen: String? = null, +) + +/** `GET /public/rust/servers/{id}/online` — who is on right now. */ +@Serializable +data class RustOnlineDto( + val players: List = emptyList(), +) + +/** + * One row of the presence board. + * + * Read from the board the bridge re-sends on every connect and every minute, + * rather than counted from connect and disconnect events — so it is right even + * after the website has missed one. **An unreachable server does not clear it**, + * deliberately: these rows are still the best answer anybody has. Presented bare + * they read as *who is on right now*, which is the one thing an offline server + * cannot be saying, so the screen has to say which it is. + */ +@Serializable +data class RustPresenceDto( + val steamId: String = "", + val name: String? = null, + val sleeping: Boolean = false, + val connectedAt: String? = null, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt new file mode 100644 index 0000000..0d2b348 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt @@ -0,0 +1,84 @@ +/* + * 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.RustApi +import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustPresenceDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustWipeDto +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The Rust module's public read path (PLAN.md §9 M14). + * + * Every read unwraps its envelope here rather than in a view model, so no screen + * holds a `…Dto` whose only job was to carry one list. Nothing is cached and + * nothing is merged: the module's tables are already the cache — the site's whole + * premise is that it answers from what a server last said rather than from the + * server — so a second copy in the app would only add a way for the two to + * disagree. + */ +@Singleton +class RustRepository @Inject constructor( + private val api: RustApi, +) { + /** Every server this site follows, with what each last reported. */ + suspend fun servers(): ApiResult> = + safeApiCall { api.getServers() }.map { it.servers } + + /** One server. A 404 here means no such server, or one an operator disabled. */ + suspend fun server(id: String): ApiResult = + safeApiCall { api.getServer(id) }.map { it.server } + + /** + * The feed. + * + * [kinds] is joined here rather than by a caller, so the query string this + * app sends exists in one place — and an **empty** list is sent as no `kind` + * parameter at all, which asks for the whole allowlist. Sending `kind=` would + * ask for a kind named the empty string. + */ + suspend fun events( + id: String, + kinds: List = emptyList(), + wipe: String? = null, + limit: Int? = null, + ): ApiResult> = 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( + id: String, + wipe: String? = null, + sort: String? = null, + limit: Int? = null, + ): ApiResult> = safeApiCall { + api.getLeaderboard( + id = id, + wipe = wipe?.takeIf { it.isNotBlank() }, + sort = sort?.takeIf { it.isNotBlank() }, + limit = limit, + ) + }.map { it.leaderboard } + + /** Every wipe this server has had, newest first. */ + suspend fun wipes(id: String): ApiResult> = + safeApiCall { api.getWipes(id) }.map { it.wipes } + + /** The presence board. Rows survive an unreachable server, by design. */ + suspend fun online(id: String): ApiResult> = + safeApiCall { api.getOnline(id) }.map { it.players } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt index a5417a8..1cd3c48 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt @@ -159,6 +159,24 @@ object Capability { */ const val SHARD = "shard" + /** + * The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5). + * + * A second game module, and therefore a second string rather than a second + * meaning for [SHARD]: a Rust site is a **fleet of servers** with a list + * above them, where a shard is one place — the surfaces are not the same + * shape and a client cannot render one as the other. + * + * `module-rust` also declares `servers`, `killfeed`, `leaderboard`, + * `presence` and `wipes`, and this gates on none of them. Every one of those + * names a SURFACE, and core flattens all modules' capabilities into one list + * — so `servers` is a word another module could declare tomorrow, which would + * silently reveal these rows on a site that does not run Rust. `rust` is the + * string only that module can mean, which is the same job [SHARD] does for + * `module-uo`. + */ + const val RUST = "rust" + /** Core's event system (events Phase 14a). Never a module's. */ const val EVENTS = "events" } 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 2e76578..8793f52 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -21,6 +21,7 @@ import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.NotificationsApi import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PublicApi +import com.runicgateway.app.data.api.RustApi import com.runicgateway.app.data.api.SsoApi import dagger.Module import dagger.Provides @@ -132,6 +133,19 @@ object NetworkModule { @Singleton fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java) + /** + * `module-rust`'s public read path (§9 M14). + * + * A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on + * a backend where an operator installed the Rust module, and the drawer rows + * that lead to them are gated on its `rust` capability. Provided + * unconditionally all the same: a Retrofit interface costs nothing until + * something calls it, and there is nowhere at injection time to ask. + */ + @Provides + @Singleton + fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::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/Polling.kt b/app/src/main/java/com/runicgateway/app/ui/Polling.kt new file mode 100644 index 0000000..d5ba21e --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/Polling.kt @@ -0,0 +1,110 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.runicgateway.app.core.result.ApiResult +import kotlinx.coroutines.delay + +/** + * Repeated reads of a surface that changes while somebody is looking at it + * (`docs/modules/rust/PLAN.md` D14, and D17 for this leg). + * + * ## Why a refresh is not a load + * + * The app has had exactly one shape for a read until now: set [UiState.Loading], + * ask, replace. That is right for opening a screen and wrong for a poll — a + * twenty-second refresh built on it would clear the killfeed, put a spinner where + * it was and re-fill it, three times a minute, for ever. The website hit the same + * wall one tier along: core's `useAsync` blanks its data on every dependency + * change, so `module-rust` bundles its own `usePolled`. This is that hook's other + * half. + * + * The rule both ends keep: **a refresh is invisible when it succeeds, and keeps + * the rows when it fails.** A site whose whole premise is "it renders while the + * game is off" must not blank itself the first time a request does. + */ + +/** How often a live surface re-reads itself while somebody is looking at it (D17). */ +const val POLL_INTERVAL_MS = 20_000L + +/** + * What a poll produced: the state to render, and whether the last attempt failed. + * + * Two fields rather than a wider [UiState] because they are two facts and a + * screen renders them in different places — the rows in the list, the failure as + * a quiet line above it. Collapsing them would force the choice this exists to + * avoid: show the error and lose the rows, or keep the rows and say nothing. + */ +data class Polled( + val state: UiState = UiState.Loading, + /** True when the most recent refresh failed **and there were rows to keep**. */ + val refreshFailed: Boolean = false, +) + +/** + * Fold a refresh into what is already on screen. + * + * Three cases, and the middle one is the whole point: + * + * - **It answered.** The new data replaces the old and any previous failure + * clears. This is the ordinary path and it is silent. + * - **It failed, and there are rows.** The rows stay exactly as they are and the + * failure is reported beside them. Nothing is blanked and nothing is retried + * on the reader's behalf — the next tick is twenty seconds away. + * - **It failed, and there is nothing yet.** There is nothing to protect, so it + * becomes an ordinary error with a retry — which is what the first load + * failing means. + * + * Pure, and takes the current state rather than reading one, so the rule is + * tested without a dispatcher, a view model or Compose. + */ +fun refreshInto(current: UiState, result: ApiResult): Polled = when { + result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false) + current is UiState.Success -> Polled(current, refreshFailed = true) + else -> Polled(result.toUiState(), refreshFailed = false) +} + +/** + * Run [block] now and every [intervalMs] for as long as this screen is resumed. + * + * `repeatOnLifecycle` is what makes this the phone's version of D14's Page + * Visibility gate, and it gets three behaviours from one line: + * + * - **Nothing runs while the app is away.** The coroutine is cancelled at + * `onPause`, so a backgrounded app makes no requests at all — not a slower + * poll, none. + * - **Coming back refreshes immediately.** The block is restarted from the top + * at `onResume`, which calls [block] before the first [delay] — so the first + * thing a returning reader sees is current, not up to twenty seconds old. + * - **A dialog or the recents switcher pauses it**, because that is what RESUMED + * means. The alternative, STARTED, keeps polling behind a partially + * obscured screen, which is precisely the reader who is not reading. + * + * **Keyed on the lifecycle owner alone, and [block] is held through + * `rememberUpdatedState`.** Keying on the block would restart the loop on every + * recomposition, because a lambda is a new object each time; capturing it without + * `rememberUpdatedState` would freeze the *first* one, so a tab change or a + * newly chosen wipe would keep refreshing the question the reader has stopped + * asking. The loop is stable and what it calls is current. + */ +@Composable +fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + val current by rememberUpdatedState(block) + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (true) { + current() + delay(intervalMs) + } + } + } +} 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 a90403e..053ade0 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -57,6 +57,7 @@ import com.runicgateway.app.core.auth.Session import com.runicgateway.app.core.web.WebHandoff import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.appearance.SiteAppearance +import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen @@ -101,6 +102,9 @@ import com.runicgateway.app.ui.shard.MarketScreen 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.RustServerScreen +import com.runicgateway.app.ui.rust.RustServersScreen import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.wiki.WikiPageScreen @@ -118,6 +122,9 @@ private val TOP_LEVEL_ROUTES = setOf( // gesture works on them. The event page and an arc are detail screens and are // deliberately absent — a back gesture there means "back", not "open the menu". Routes.EVENTS, Routes.MY_EVENTS, + // The Rust server list is a drawer row (M14); one server's page is a detail + // screen and is deliberately absent — a back gesture there means "back". + Routes.RUST, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, ) @@ -140,6 +147,7 @@ fun RunicApp( onDeepLinkConsumed: () -> Unit = {}, sessionViewModel: SessionViewModel = hiltViewModel(), inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(), + rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(), ) { val brand = appearance.brand val navController = rememberNavController() @@ -163,6 +171,17 @@ fun RunicApp( } val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle() + // How many people are on the Rust fleet, for the drawer row's badge — the + // phone's answer to D15's footer count (M14). Refreshed with the capability + // answer rather than on a timer: a badge is a glance, not a feed, and this is + // the only place that knows whether the module is installed at all. A host + // that has not answered yet asks nothing, so a cold start makes no request + // until it knows there is something to ask about. + val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle() + LaunchedEffect(capabilities) { + val caps = capabilities + if (caps != null) rustBadgeViewModel.refresh(Capability.RUST in caps) + } // The badge follows the session, so signing out clears it rather than leaving // the previous account's count on the drawer. LaunchedEffect(session) { inboxBadgeViewModel.refresh() } @@ -267,10 +286,17 @@ fun RunicApp( colors = drawerItemColors, indented = true, unread = unread, + rustOnline = rustOnline, ) { openNode(child) } } } else { - NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) } + NavRow( + node = node, + currentRoute = currentRoute, + colors = drawerItemColors, + unread = unread, + rustOnline = rustOnline, + ) { openNode(node) } } } @@ -386,6 +412,7 @@ private fun NavRow( colors: NavigationDrawerItemColors, indented: Boolean = false, unread: Int = 0, + rustOnline: Int = 0, onClick: () -> Unit, ) { val route = when (node) { @@ -405,6 +432,15 @@ private fun NavRow( // admin's own nav override pointing at it, since the badge belongs to the // destination, not to the bundled entry. val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS + // The live player count rides on whichever row leads to the Rust list, for the + // same reason the unread count rides on whichever leads to the inbox — the + // number belongs to the destination, not to the bundled entry, so an admin's + // own nav override pointing there carries it too. + // + // **Zero renders nothing**, rather than a `0`: an empty fleet is not a + // notification, and a badge that read `0` on a site whose servers are simply + // quiet would be worse than no badge at all. + val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST NavigationDrawerItem( label = { Text(label) }, @@ -432,6 +468,19 @@ private fun NavRow( ) } } + showsRustOnline -> { + { + // Named for a screen reader: "42" beside "Rust servers" reads + // as a count to a sighted user and as a bare number to + // everyone else. + val spoken = stringResource(R.string.rust_online_badge, rustOnline) + Text( + text = rustOnline.toString(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.semantics { contentDescription = spoken }, + ) + } + } else -> null }, colors = colors, @@ -573,6 +622,17 @@ private fun RunicNavHost( ) { entry -> AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty()) } + // The Rust module's two screens (M14). Not under `shard/`: a different game, + // a different shape — a fleet with a list above it rather than one place. + composable(Routes.RUST) { + RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) }) + } + composable( + route = Routes.RUST_SERVER, + arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }), + ) { + RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) }) + } composable(Routes.WIKI) { WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) }) } 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 2c0056c..cdf8ff5 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 @@ -128,6 +128,17 @@ val APP_MENU: List = listOf( feature = ShardFeature.MARKET, capability = Capability.SHARD, ), + // The Rust module (M14). ONE row, because the module's whole public surface is + // one list and one page beneath it — `/rust` IS the server list, not a hub + // above one. + // + // **No `feature`, and that is not an omission.** The visibility framework is + // `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/` + // and none under core's), and §2.7 forbids a module importing another's — so + // `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds + // one; until then these routes are public to everyone the site is public to, + // and a `feature` here would be gating on a flag nothing publishes. + MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST), MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt index 69766cf..b9da697 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt @@ -70,6 +70,15 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory * }) * ``` * + * and from `module-rust/client/src/entry.jsx`, which registers one (M14): + * + * ```jsx + * registry.registerNav(ID, { + * area: 'public', + * items: [{ label: 'Servers', to: '/rust' }], + * }) + * ``` + * * None of those nine declares an `order`, so `mergeFlat` appends them after core's * rows in registration order — which is the order they are listed in below. * @@ -119,6 +128,16 @@ val WEBSITE_PUBLIC_NAV: List = listOf( WebNavPath("/uo/atlas", Routes.ATLAS), WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS), WebNavPath("/uo/market", Routes.SHARD_MARKET), + // module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }` + // and nothing else — `/rust` IS the server list, because core strips the + // trailing separator from a module route registered with `path: ''`. + // + // **Both modules can be installed on one backend**, and then the nav is core's + // eight plus ten. This table is a superset by design: a path here for a module + // an operator has NOT installed never appears in that backend's nav and so is + // never looked up, while a path missing from it makes a link that exists hand + // off to a browser. + WebNavPath("/rust", Routes.RUST), ) /** @@ -178,7 +197,12 @@ private val RESERVED_TOP_LEVEL = setOf( // Only ids the app knows about need listing: an unknown module's `/` would // resolve to a CMS page that 404s, which is the same answer the browser gives // it, and core cannot enumerate them for us here anyway. - "uo", + // + // `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to + // the server list, and it does so through the nav table above — this set only + // stops the CMS-page fallback claiming it. Without the entry a site with the + // module absent would open a page-not-found screen instead of the browser. + "uo", "rust", ) /** @@ -209,6 +233,7 @@ private val RESERVED_TOP_LEVEL = setOf( * // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id, * // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market, * // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug + * // /rust, /rust/servers/:id * // CMS pages: top-level /:slug, matched only after the named routes above * } /> * ``` @@ -231,6 +256,8 @@ private val RESERVED_TOP_LEVEL = setOf( * /uo/ → the mapped shard route (§6.2) * /uo/atlas/ → ATLAS_CREATURE * /uo/market/vendors/ → SHARD_MARKET_VENDOR + * /rust → RUST (module-rust's server list) + * /rust/servers/ → RUST_SERVER * /site/about → PAGE("about") * / → PAGE(slug), unless is reserved * anything else → null, i.e. the Custom Tab @@ -295,6 +322,10 @@ fun resolveWebPath(path: String?): String? { Routes.atlasCreature(segments[2]) segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" -> Routes.marketVendor(segments[3]) + // module-rust's one page below the list. `/rust` itself is already + // answered by the nav table above, before this fallback is reached. + segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" -> + Routes.rustServer(segments[2]) else -> null } } @@ -322,3 +353,6 @@ private fun runParam(query: String): String? { * module is countable. It is a literal on purpose — see the file header. */ private const val MODULE_UO = "uo" + +/** The second game module's id (M14). A literal for the same reason. */ +private const val MODULE_RUST = "rust" 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 c21ad96..e7308f1 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 @@ -70,6 +70,22 @@ object Routes { const val EVENT_SERIES = "events/series/{slug}" const val MY_EVENTS = "account/events" + /** + * The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13). + * + * **[RUST] is the server list, not a hub above one.** The module registers its + * pages with `path: ''` and core strips the trailing separator, so `/rust` on + * the website *is* the list — there is no landing page between the drawer row + * and the servers, and adding one here would invent a screen the website does + * not have. + * + * **A different game, so a different route tree.** These are deliberately not + * folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The + * two can be installed on the same backend, and then both trees exist at once. + */ + const val RUST = "rust" + const val RUST_SERVER = "rust/servers/{serverId}" + /** Public shard hub (§6.2). */ const val SHARD = "shard" @@ -121,6 +137,7 @@ object Routes { const val ID_OR_SLUG = "idOrSlug" const val SERIAL = "serial" const val RUN = "run" + const val SERVER_ID = "serverId" } fun page(slug: String) = "page/$slug" @@ -140,6 +157,42 @@ object Routes { /** One player vendor's shop, by in-game (hex) serial. */ fun marketVendor(serial: String) = "shard/market/$serial" + /** + * One Rust server's page. + * + * The id is a slug an operator chose, so it is encoded: nothing stops one + * carrying a character a path would otherwise eat, and a server nobody can + * open is a worse failure than a name nobody can read. + * + * **Encoded here rather than with `android.net.Uri`**, which is a stub in a + * JVM unit test and throws "not mocked" — this object is pure and every test + * that builds a route would have to become an instrumented one to keep it + * that way. + */ + fun rustServer(id: String) = "rust/servers/${encodePathSegment(id)}" + + /** + * Percent-encode one path segment, allowing only the unreserved set. + * + * Deliberately stricter than it needs to be: encoding a character that did + * not need it still round-trips, where missing one that did produces a route + * NavHost matches differently from the one that was built. UTF-8 first, so a + * non-ASCII name is encoded per byte rather than per character. + */ + private fun encodePathSegment(value: String): String = buildString { + for (byte in value.toByteArray(Charsets.UTF_8)) { + val code = byte.toInt() and 0xFF + val char = code.toChar() + if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) { + append(char) + } else { + append('%').append(code.toString(16).uppercase().padStart(2, '0')) + } + } + } + + private const val UNRESERVED = "-._~" + /** One creature's atlas page, by slug. */ fun atlasCreature(slug: String) = "atlas/$slug" diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustBadgeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustBadgeViewModel.kt new file mode 100644 index 0000000..0750f0c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustBadgeViewModel.kt @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.repository.RustRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The live player count on the drawer's Rust row — the phone's answer to D15 + * (`docs/modules/rust/PLAN.md` §17.4). + * + * ## Why the drawer and not a footer + * + * D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which + * exists because every page of the website renders the same footer. The app has + * no footer and no slot; what it has is a drawer row per surface and, already, a + * precedent for a number beside one — the inbox's unread badge, in the same + * `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So + * the count rides there. + * + * **The number is players, not servers.** A badge is one integer, and of the two + * halves of D15's line the live one is how many people are on: a server count + * changes when an operator edits configuration, which is not news, and is visible + * on the page the row opens anyway. + * + * ## What keeps it honest + * + * The website's version renders nothing until it has an answer, nothing at all if + * the request fails, and never polls — because one request per page view is a + * cost and a timer in a footer on every page is a different kind of thing. All + * three rules hold here: + * + * - **Zero renders nothing.** No badge, rather than a `0` — an empty server is + * not a notification. + * - **A failure leaves the last count** rather than dropping to zero. A moment + * with no connectivity is not everybody logging off. + * - **It refreshes on resume, with the unread badge**, and never on a timer. The + * count is a glance, not a feed. + * + * It is asked for **only when the module is installed** — the caller gates on the + * `rust` capability — so a site running a different game makes no request at all. + */ +@HiltViewModel +class RustBadgeViewModel @Inject constructor( + private val repository: RustRepository, +) : ViewModel() { + + private val _online = MutableStateFlow(0) + + /** How many people are on across every server, or 0 when there is nothing to say. */ + val online: StateFlow = _online.asStateFlow() + + /** + * Ask, if the Rust module is there. + * + * [installed] is passed in rather than read here so this holds no opinion + * about capabilities: the drawer already knows, and a view model that + * re-derived it would be a second copy of a rule that lives in one place. + * Absent — the host has not answered yet — makes no request and keeps + * whatever is showing. + */ + fun refresh(installed: Boolean) { + if (!installed) { + _online.value = 0 + return + } + + viewModelScope.launch { + when (val result = repository.servers()) { + // A server that is stale or unreachable already answers `online: + // false` with `players: 0`, so summing the whole list needs no + // second staleness rule here. + is ApiResult.Ok -> _online.value = result.data.sumOf { it.players } + // Keep the last number. See the class doc. + else -> Unit + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt new file mode 100644 index 0000000..fdf2c63 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt @@ -0,0 +1,199 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustEventDto + +/** + * One stored frame as one line of a feed — the Kotlin half of `module-rust`'s + * `client/src/lib/feed.js` (PLAN.md §9 M14). + * + * `GET /public/rust/servers/{id}/events` answers rows shaped + * `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the + * bridge plugin emitted. Everything a killfeed line needs is in there, under the + * names the plugin wrote, and **this file is the one place in the app that knows + * them**. + * + * ## It returns parts, not a sentence + * + * A row wants the names emphasised and the detail muted, and a function returning + * `"Alice killed Bob"` would force the screen to re-parse its own output to style + * it. Parts also make this testable without Compose, which is the only way this + * leg has real coverage of what a feed row says. + * + * ## The rule for an unknown kind + * + * **It renders as itself.** A later protocol adds kinds and an operator's module + * may be older than their game host, so a feed that dropped what it did not + * recognise would be a screen quietly saying less than the truth. The server's + * allowlist has already decided the row may be seen; what is left here is + * presentation, and the honest presentation of a kind we have no words for is its + * own name. + */ + +/** The row's category, for the small colour a screen gives it — never for meaning. */ +enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER } + +/** + * One row, ready to render. + * + * [actor] and [subject] are names and are emphasised; [verb] and [detail] are + * prose. Any of them may be null or empty. + * + * [join] is what goes between the actor and the verb, and it exists for exactly + * one case: chat. "Brannock see you in september" is not a sentence anybody + * writes, and putting the colon in the message would put presentation inside text + * a player typed. + */ +data class FeedLine( + val tone: FeedTone, + val actor: String? = null, + val join: String = " ", + val verb: String = "", + val subject: String? = null, + val detail: String = "", +) + +/** One filter the feed offers, and the kinds it asks the API for. */ +data class FeedFilter(val id: String, val label: String, val kinds: List) + +/** + * Kinds this feed asks for. + * + * `player.tally` is public and deliberately **not** here: it is an aggregate the + * plugin flushes every sixty seconds per active player, so a feed including it + * would be mostly wood counts. It is the leaderboard's input, and the leaderboard + * is where it shows up. + */ +val FEED_KINDS: List = listOf( + "player.death", + "player.connected", + "player.disconnected", + "player.respawned", + "player.chat", + "server.wipe", + "server.initialized", + "server.shutdown", +) + +/** The filters the feed offers. The first is the default and asks for everything. */ +val FEED_FILTERS: List = listOf( + FeedFilter("all", "Everything", FEED_KINDS), + FeedFilter("kills", "Kills", listOf("player.death")), + FeedFilter("chat", "Chat", listOf("player.chat")), + FeedFilter( + "sessions", + "Comings and goings", + listOf("player.connected", "player.disconnected", "player.respawned"), + ), + FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")), +) + +/** The kinds a filter id asks for; an id nobody offers falls back to everything. */ +fun kindsFor(filterId: String): List = + (FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds + +/** One row as the parts a screen renders. */ +fun describe(row: RustEventDto): FeedLine { + val name = row.str("name") + + return when (row.kind) { + "player.death" -> death(row, name) + + "player.connected" -> + FeedLine(FeedTone.JOIN, actor = name, verb = "connected") + + "player.disconnected" -> FeedLine( + tone = FeedTone.LEAVE, + actor = name, + verb = "disconnected", + // Two optional halves, and the session is the interesting one. The + // plugin OMITS `sessionSec` for a player who was already on when it + // loaded, so an absent value means "unknown" and never zero — which is + // why this reads the parsed number rather than trusting a default. + detail = listOfNotNull( + row.str("reason"), + row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" }, + ).joinToString(" · "), + ) + + "player.respawned" -> + FeedLine(FeedTone.JOIN, actor = name, verb = "respawned") + + "player.chat" -> FeedLine( + tone = FeedTone.CHAT, + actor = name, + join = ": ", + // The message is the row, so it goes in `verb` where a screen renders + // it unemphasised — and it is the one field on this wire whose bytes a + // player chooses. Compose renders it as text and never as markup; + // nothing here may ever stop doing that. + verb = row.str("message").orEmpty(), + detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(), + ) + + "server.wipe" -> FeedLine( + tone = FeedTone.SERVER, + verb = "The map was wiped", + detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(), + ) + + "server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up") + + "server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down") + + else -> FeedLine( + tone = FeedTone.OTHER, + actor = name, + verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown", + ) + } +} + +/** + * A death, which is four different sentences. + * + * The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so + * a reader does not have to guess from an absent field, and collapsing any two of + * them loses something. A killfeed reporting a fall as a kill by nobody is the + * failure this avoids. + */ +private fun death(row: RustEventDto, name: String?): FeedLine { + val where = listOfNotNull( + row.str("weapon")?.let { "with ${prefabName(it)}" }, + row.num("distance")?.let { "${Math.round(it)}m" }, + row.str("grid"), + if (row.flag("sleeping")) "while sleeping" else null, + ).joinToString(" · ") + + return when (row.str("attackerType")) { + "player" -> FeedLine( + tone = FeedTone.KILL, + actor = row.str("attackerName"), + verb = "killed", + subject = name, + detail = where, + ) + + "self" -> FeedLine( + tone = FeedTone.DEATH, + actor = name, + verb = "died by their own hand", + detail = where, + ) + + "npc" -> FeedLine( + tone = FeedTone.DEATH, + actor = prefabName(row.str("attackerName")).ifBlank { "Something" }, + verb = "killed", + subject = name, + detail = where, + ) + + // `environment` and anything else: falling, drowning, the world. The + // plugin legitimately has no attacker on this path, so an ABSENT type is + // this case rather than a missing field to complain about. + else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt new file mode 100644 index 0000000..aae292b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -0,0 +1,154 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.core.time.parseWireInstant +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** + * Formatting for the Rust screens — pure, no Compose, no Android (PLAN.md §9 + * M14). + * + * The Kotlin half of `module-rust`'s `client/src/lib/format.js`, and it is a + * deliberate second implementation rather than something shared: the two clients + * have different formatting libraries under them (`Intl` there, `java.time` + * here), and the thing worth keeping identical is the **rules**, not the code. + * Those rules are restated here beside each function so a reader can check them + * against the website without opening it. + * + * Everything takes `now` as a parameter, so a boundary is testable rather than a + * property of the machine the test runs on. + */ + +/** + * The stamp on a feed row. + * + * **Today's rows get a time; everything older gets a date as well.** The feed can + * be filtered to a past wipe, and a row from six weeks ago rendered as `14:03` + * reads as this afternoon — which is exactly what the website's own page walk + * found, three events from August all apparently a few minutes old. The boundary + * is the **calendar day**, not a duration, because that is what a reader means by + * "what time was that". + */ +fun feedClock( + value: String?, + epochMillis: Long? = null, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + val at = epochMillis?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: parseWireInstant(value) ?: return "" + + val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + .withLocale(locale) + .format(at.atZone(zone)) + + val sameDay = at.atZone(zone).toLocalDate() == now.atZone(zone).toLocalDate() + if (sameDay) return time + + val date = DateTimeFormatter.ofPattern("d MMM", locale).format(at.atZone(zone)) + return "$date $time" +} + +/** A date, for a wipe: the thing people actually compare wipes by. */ +fun wipeDay( + value: String?, + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String? { + val at = parseWireInstant(value) ?: return null + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale) + .format(at.atZone(zone)) +} + +/** + * "3 minutes ago", for a "last reported" line. + * + * Returns null rather than a word for an absent stamp, so the caller decides what + * "never" looks like in its own layout — on this surface a server that has never + * reported is a real and ordinary state, not a missing value to apologise for. + */ +fun rustAgo(value: String?, now: Instant = Instant.now()): String? { + val at = parseWireInstant(value) ?: return null + val seconds = java.time.Duration.between(at, now).seconds + + // Under a minute in either direction, say the thing rather than "in 0 seconds". + if (kotlin.math.abs(seconds) < 45) return "just now" + + val future = seconds < 0 + val magnitude = kotlin.math.abs(seconds) + val (unit, size) = AGO_UNITS.first { magnitude >= it.second } + val amount = Math.round(magnitude.toDouble() / size) + val plural = if (amount == 1L) unit else "${unit}s" + + return if (future) "in $amount $plural" else "$amount $plural ago" +} + +private val AGO_UNITS = listOf( + "year" to 31_536_000L, + "month" to 2_592_000L, + "week" to 604_800L, + "day" to 86_400L, + "hour" to 3_600L, + "minute" to 60L, + "second" to 1L, +) + +/** + * A session or a playtime, as `4h 12m`. + * + * Seconds are dropped above a minute and kept below it: a two-hour session + * reported to the second is noise, and a forty-second one reported as "0m" is + * wrong. + */ +fun playtime(seconds: Long?): String { + val total = seconds ?: return "—" + if (total <= 0) return "—" + if (total < 60) return "${total}s" + + val hours = total / 3600 + val minutes = Math.round((total % 3600) / 60.0) + + return when { + hours == 0L -> "${minutes}m" + minutes == 0L -> "${hours}h" + else -> "${hours}h ${minutes}m" + } +} + +/** + * A prefab short name as something readable — `patrolhelicopter` stays itself, + * `rifle.ak` becomes `rifle ak`. + * + * Deliberately a light touch rather than a lookup table: a table mapping every + * Rust prefab to a pretty name is a second copy of the game's item list that goes + * stale every wipe, and the short name is what a Rust player reads on their own + * server console anyway. + */ +fun prefabName(name: String?): String { + if (name.isNullOrBlank()) return "" + return name.replace(Regex("[_.]+"), " ").trim() +} + +/** A steam id, shortened for a table cell, without pretending it is a name. */ +fun shortSteamId(steamId: String?): String { + val id = steamId.orEmpty() + return if (id.length > 10) "…${id.takeLast(6)}" else id +} + +/** + * What to call a player who has no name yet. + * + * The presence board and the leaderboard both carry a nullable `name`: the plugin + * knows a steam id before it knows anything else. Showing a shortened id is + * honest — it is not a name and does not look like one — where "Unknown" would + * lose the only identifier there is. + */ +fun playerLabel(name: String?, steamId: String?): String = + name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt new file mode 100644 index 0000000..db7a543 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -0,0 +1,535 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.RustEventDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustPresenceDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustWipeDto +import com.runicgateway.app.ui.ErrorKind +import com.runicgateway.app.ui.PollWhileResumed +import com.runicgateway.app.ui.Polled +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.EmptyView +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.SectionLabel +import com.runicgateway.app.ui.components.ShardCard +import com.runicgateway.app.ui.components.StatusPill + +/** + * One Rust server: the feed, the leaderboard, who is on, and the wipes (D13). + * + * **One screen with tabs, not four destinations** — the same call the website + * makes, and more obviously right on a phone: the four panels are four questions + * about one thing, and a reader moving between them is not navigating. + * + * The phase criterion lives here. With the server unreachable this still renders + * its map, size, seed, wipe date, killfeed, leaderboards, last known presence + * board and wipe history, because every one of those is read from the website's + * own tables rather than from the game. + */ +@Composable +fun RustServerScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: RustServerViewModel = hiltViewModel(), +) { + val ui by viewModel.state.collectAsStateWithLifecycle() + + PollWhileResumed { viewModel.refresh() } + + when (val s = ui.server.state) { + is UiState.Loading -> LoadingView(modifier) + + // **A mistyped address is not a fault and must not be dressed as one.** + // The website's first version put its generic error panel under this + // heading, so an unknown id read "No such server / Something went wrong" + // and sent a reader looking for an outage. A 404 is its own answer; the + // error panel is kept for a request that failed for a reason nobody can + // see. A server an operator disabled answers the same 404 — switching one + // off is not switching it into a refusal. + is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) { + MissingServer(onBack, modifier) + } else { + ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + } + + is UiState.Success -> ServerDetail(s.data, ui, viewModel, modifier) + } +} + +@Composable +private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge) + Text( + text = stringResource(R.string.rust_no_such_server_detail), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.rust_back_to_servers), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp), + ) + } +} + +@Composable +private fun ServerDetail( + server: RustServerDto, + ui: RustServerUi, + viewModel: RustServerViewModel, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxSize()) { + ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes) + + val tabs = RustTab.entries + ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) { + tabs.forEach { tab -> + Tab( + selected = tab == ui.tab, + onClick = { viewModel.selectTab(tab) }, + text = { Text(stringResource(tabLabel(tab))) }, + ) + } + } + + when (ui.tab) { + RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed) + RustTab.LEADERBOARD -> LeaderboardPanel( + ui.leaderboard, + ui.sort, + viewModel::selectSort, + viewModel::retryLeaderboard, + ) + RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline) + RustTab.WIPES -> WipesPanel( + ui.wipes, + server.wipeId, + ui.selectedWipe, + viewModel::openWipe, + viewModel::retryWipes, + ) + } + } +} + +private fun tabLabel(tab: RustTab): Int = when (tab) { + RustTab.FEED -> R.string.rust_tab_feed + RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard + RustTab.ONLINE -> R.string.rust_tab_online + RustTab.WIPES -> R.string.rust_tab_wipes +} + +@Composable +private fun ServerHeader( + server: RustServerDto, + selectedWipe: String?, + onSelectWipe: (String?) -> Unit, + wipes: UiState>, +) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall) + + describeWorld(server)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (server.online) { + StatusPill( + text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers), + tone = PillTone.Success, + ) + } else { + StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral) + } + Text( + text = lastReported(server), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // The wipe picker sits above the tabs because it filters two of them. It + // is absent until the wipe list has loaded — offering a filter with one + // option would look like a server that has only ever had one wipe. + val available = (wipes as? UiState.Success)?.data.orEmpty() + if (available.isNotEmpty()) { + WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe) + } + } +} + +@Composable +private fun WipeFilter( + wipes: List, + currentWipeId: String?, + selected: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // **Null is all time, and it is the default.** It is a real choice rather + // than an absent filter: all-time is the per-wipe rows summed, which is + // the answer to "who plays here", where a wipe is the answer to "who is + // winning now". + FilterChip( + selected = selected == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.rust_all_time)) }, + ) + wipes.forEach { wipe -> + val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId + FilterChip( + selected = selected == wipe.wipeId, + onClick = { onSelect(wipe.wipeId) }, + label = { + Text( + if (wipe.wipeId == currentWipeId) { + stringResource(R.string.rust_wipe_current, label) + } else { + label + }, + ) + }, + ) + } + } +} + +// ── Feed ────────────────────────────────────────────────────────────────── + +@Composable +private fun FeedPanel( + feed: Polled>, + filterId: String, + onFilter: (String) -> Unit, + onRetry: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FEED_FILTERS.forEach { filter -> + FilterChip( + selected = filter.id == filterId, + onClick = { onFilter(filter.id) }, + label = { Text(filter.label) }, + ) + } + } + + when (val s = feed.state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) + is UiState.Success -> if (s.data.isEmpty()) { + EmptyView(stringResource(R.string.rust_feed_empty)) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (feed.refreshFailed) { + item { RefreshFailedLine() } + } + items(s.data, key = { it.id }) { FeedRow(it) } + } + } + } + } +} + +@Composable +private fun FeedRow(row: RustEventDto) { + val line = describe(row) + + Row(verticalAlignment = Alignment.Top) { + // The stamp carries a date for anything not from today — a row from six + // weeks ago rendered as a bare time reads as this afternoon, which is + // exactly what happens the moment the feed is filtered to a past wipe. + Text( + text = feedClock(value = null, epochMillis = row.t), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 10.dp, top = 2.dp), + ) + Column { + Row { + line.actor?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text(line.join, style = MaterialTheme.typography.bodyMedium) + } + // The chat message lands here, and it is the one field on this + // wire whose bytes a player chooses. Compose renders it as text + // and never as markup; nothing here may ever stop doing that. + Text(line.verb, style = MaterialTheme.typography.bodyMedium) + line.subject?.let { + Text(" ", style = MaterialTheme.typography.bodyMedium) + Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + } + if (line.detail.isNotBlank()) { + Text( + text = line.detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// ── Leaderboard ─────────────────────────────────────────────────────────── + +/** + * The columns, and which of them the API can sort by. + * + * `structures` has no sort on the wire and therefore no tap here — a header that + * sorts by something other than what it says is worse than one that does not + * sort. + */ +private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String) + +private val RUST_COLUMNS = listOf( + RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() }, + RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() }, + RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() }, + RustColumn(R.string.rust_col_structures, null) { it.structures.toString() }, + RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) }, +) + +@Composable +private fun LeaderboardPanel( + state: UiState>, + sort: String, + onSort: (String) -> Unit, + onRetry: () -> Unit, +) { + when (state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(state.kind, onRetry = onRetry) + is UiState.Success -> if (state.data.isEmpty()) { + // **Empty is a real answer here and is not "no data".** All-time is + // the per-wipe rows summed, so a player who appears only in an older + // wipe drops out of the current one rather than reading zero — an + // empty board for a wipe means nobody scored on that map. + EmptyView(stringResource(R.string.rust_leaderboard_empty)) + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Row(Modifier.fillMaxWidth()) { + SectionLabel(stringResource(R.string.rust_col_player), Modifier.weight(1f)) + RUST_COLUMNS.forEach { column -> + SectionLabel( + text = stringResource(column.labelRes), + modifier = Modifier + .weight(1f) + .then( + if (column.sort != null) { + Modifier.clickable { onSort(column.sort) } + } else { + Modifier + }, + ), + ) + } + } + } + items(state.data, key = { it.steamId }) { row -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = playerLabel(row.name, row.steamId), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + RUST_COLUMNS.forEach { column -> + Text( + text = column.value(row), + style = MaterialTheme.typography.bodySmall, + color = if (column.sort == sort) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } +} + +// ── Online ──────────────────────────────────────────────────────────────── + +@Composable +private fun OnlinePanel( + online: Polled>, + 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()) { + EmptyView( + stringResource( + if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline, + ), + ) + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // **The board is the last one that ARRIVED, and an unreachable + // server does not clear it** — deliberately, because these rows + // are still the best answer anybody has. Presented bare they read + // as "these people are on right now", which is the one thing an + // offline server cannot be saying. So the panel says which it is. + item { + Text( + text = stringResource( + if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (online.refreshFailed) { + item { RefreshFailedLine() } + } + items(s.data, key = { it.steamId }) { player -> + ShardCard(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = playerLabel(player.name, player.steamId), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (player.sleeping) { + StatusPill( + text = stringResource(R.string.rust_sleeping), + tone = PillTone.Neutral, + ) + } + } + } + } + } + } + } +} + +// ── Wipes ───────────────────────────────────────────────────────────────── + +@Composable +private fun WipesPanel( + state: UiState>, + currentWipeId: String?, + selected: String?, + onOpenWipe: (String) -> Unit, + onRetry: () -> Unit, +) { + when (state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(state.kind, onRetry = onRetry) + is UiState.Success -> if (state.data.isEmpty()) { + EmptyView(stringResource(R.string.rust_wipes_empty)) + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.data, key = { it.wipeId }) { wipe -> + ShardCard( + Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) }, + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (wipe.wipeId == currentWipeId) { + StatusPill( + text = stringResource(R.string.rust_wipe_this_one), + tone = PillTone.Success, + ) + } else if (wipe.wipeId == selected) { + StatusPill( + text = stringResource(R.string.rust_wipe_selected), + tone = PillTone.Info, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun RefreshFailedLine() { + Text( + text = stringResource(R.string.rust_refresh_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt new file mode 100644 index 0000000..2c1500b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt @@ -0,0 +1,268 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +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.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustPresenceDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustWipeDto +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.Polled +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.navigation.Routes +import com.runicgateway.app.ui.refreshInto +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 four sections of a server's page (D13). */ +enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES } + +/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */ +object RustSort { + const val KILLS = "kills" + const val DEATHS = "deaths" + const val NPC_KILLS = "npcKills" + const val PLAYTIME = "playtime" +} + +/** + * Everything one server's page is showing. + * + * One state object rather than eight flows: every panel on the page is about the + * same server and the same selected wipe, and a screen that collected them + * separately could render a leaderboard for one wipe beside a feed for another + * for a frame. + */ +data class RustServerUi( + val serverId: String = "", + val server: Polled = Polled(), + val tab: RustTab = RustTab.FEED, + val filterId: String = "all", + 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> = Polled(), + val online: Polled> = Polled(), + val leaderboard: UiState> = UiState.Loading, + val wipes: UiState> = UiState.Loading, +) + +/** + * One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14). + * + * ## What polls and what does not + * + * D14, and it is a statement about the questions rather than about cost: the + * **feed**, **who is on** and the **server's own line** change while somebody is + * looking at the page, and the **leaderboard** and the **wipe list** do not in + * any way a reader would want to watch. A leaderboard that re-sorted itself under + * a finger every twenty seconds would be worse than a stale one. + * + * Only the **visible** live panel is polled. The website can afford to mount the + * one tab it is showing; here the tabs are one screen, so the refresh asks what + * the reader is actually looking at. + * + * ## Changing the question versus asking it again + * + * A poll is the same question asked again, so it keeps what is on screen + * ([refreshInto]). Changing the filter, the sort or the wipe is a **different + * question**, so the panel blanks and loads — what is there is an answer to + * something the reader has stopped asking, and leaving it up while the new one + * arrives would show a killfeed for last wipe under a heading naming this one. + */ +@HiltViewModel +class RustServerViewModel @Inject constructor( + private val repository: RustRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: "" + + private val _state = MutableStateFlow(RustServerUi(serverId = serverId)) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + /** A first load or a retry of the whole page. */ + fun load() { + _state.update { it.copy(server = Polled(UiState.Loading), feed = Polled(UiState.Loading)) } + viewModelScope.launch { + askServer() + askFeed() + } + } + + /** + * The poll tick. + * + * The server line always, and then whichever live panel is on screen. A tab + * showing the leaderboard or the wipes does no extra work — the reader is + * looking at something that does not move. + */ + fun refresh() { + viewModelScope.launch { + askServer() + when (_state.value.tab) { + RustTab.FEED -> askFeed() + RustTab.ONLINE -> askOnline() + RustTab.LEADERBOARD, RustTab.WIPES -> Unit + } + } + } + + /** + * Open a tab, loading its panel the first time it is opened. + * + * The two that do not poll are loaded exactly once per question: re-asking on + * every tab switch would put a spinner over a leaderboard the reader has + * already read, for an answer that cannot have changed while they were three + * taps away. + */ + fun selectTab(tab: RustTab) { + val already = _state.value + _state.update { it.copy(tab = tab) } + + viewModelScope.launch { + when (tab) { + RustTab.FEED -> if (already.feed.state !is UiState.Success) askFeed() + RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline() + RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard() + RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes() + } + } + } + + /** A different question for the feed: blank it and ask. */ + fun selectFilter(filterId: String) { + if (filterId == _state.value.filterId) return + _state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) } + viewModelScope.launch { askFeed() } + } + + /** A different question for the leaderboard: blank it and ask. */ + fun selectSort(sort: String) { + if (sort == _state.value.sort) return + _state.update { it.copy(sort = sort, leaderboard = UiState.Loading) } + viewModelScope.launch { askLeaderboard() } + } + + /** + * Pick a wipe, or all time with null. + * + * It is the one selection that changes **two** panels, so both are blanked — + * and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab + * does not fetch a leaderboard nobody has opened. + */ + fun selectWipe(wipeId: String?) { + if (wipeId == _state.value.selectedWipe) return + + val hadLeaderboard = _state.value.leaderboard is UiState.Success + _state.update { + it.copy( + selectedWipe = wipeId, + feed = Polled(UiState.Loading), + leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard, + ) + } + + viewModelScope.launch { + askFeed() + if (hadLeaderboard) askLeaderboard() + } + } + + /** + * Pick a wipe from the Wipes tab, which is a navigation as much as a filter. + * + * The question it asks is "what happened during that map", and the answer is + * the feed — so it lands there rather than leaving the reader on a list of + * dates with nothing visibly changed. + */ + fun openWipe(wipeId: String) { + selectWipe(wipeId) + selectTab(RustTab.FEED) + } + + /** + * Retry one panel after its own load failed. + * + * Four entry points rather than one, because a failed leaderboard is not a + * reason to re-read the feed the reader can already see — and [load] is the + * whole page, which is right for a failed *server* read and heavy-handed for + * anything else. + */ + fun retryFeed() { + _state.update { it.copy(feed = Polled(UiState.Loading)) } + viewModelScope.launch { askFeed() } + } + + fun retryLeaderboard() { + _state.update { it.copy(leaderboard = UiState.Loading) } + viewModelScope.launch { askLeaderboard() } + } + + fun retryOnline() { + _state.update { it.copy(online = Polled(UiState.Loading)) } + viewModelScope.launch { askOnline() } + } + + fun retryWipes() { + _state.update { it.copy(wipes = UiState.Loading) } + viewModelScope.launch { askWipes() } + } + + private suspend fun askServer() { + val result = repository.server(serverId) + _state.update { it.copy(server = refreshInto(it.server.state, result)) } + } + + private suspend fun askFeed() { + val current = _state.value + val result = repository.events( + id = serverId, + kinds = kindsFor(current.filterId), + wipe = current.selectedWipe, + limit = FEED_LIMIT, + ) + _state.update { it.copy(feed = refreshInto(it.feed.state, result)) } + } + + private suspend fun askOnline() { + val result = repository.online(serverId) + _state.update { it.copy(online = refreshInto(it.online.state, result)) } + } + + private suspend fun askLeaderboard() { + val current = _state.value + val result = repository.leaderboard( + id = serverId, + wipe = current.selectedWipe, + sort = current.sort, + limit = LEADERBOARD_LIMIT, + ) + _state.update { it.copy(leaderboard = result.toUiState()) } + } + + private suspend fun askWipes() { + _state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) } + } + + private companion object { + /** Matches the website's feed page size; the server caps at 200 regardless. */ + const val FEED_LIMIT = 100 + const val LEADERBOARD_LIMIT = 50 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt new file mode 100644 index 0000000..fdb45bf --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt @@ -0,0 +1,185 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.RustServerDto +import com.runicgateway.app.ui.PollWhileResumed +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.EmptyView +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 + +/** + * Every Rust server this site follows — the module's landing page (D8, D12). + * + * **The phase criterion is this screen with every server off.** Nothing here is a + * live call to a game host: the website answers from its own tables, so a fleet + * that has been down for a week renders a week of last-known state rather than an + * error. The one thing that can fail is the website itself. + */ +@Composable +fun RustServersScreen( + onOpenServer: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: RustServersViewModel = hiltViewModel(), +) { + val polled by viewModel.state.collectAsStateWithLifecycle() + + PollWhileResumed { viewModel.refresh() } + + when (val s = polled.state) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Success -> { + if (s.data.isEmpty()) { + EmptyView(stringResource(R.string.rust_servers_empty), modifier) + } else { + ServerList(s.data, polled.refreshFailed, onOpenServer, modifier) + } + } + } +} + +@Composable +private fun ServerList( + servers: List, + refreshFailed: Boolean, + onOpenServer: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // A failed refresh says so and changes nothing else. The rows below it are + // the last good answer and stay exactly as they were — blanking them is + // the one thing a site whose premise is "it renders while the game is off" + // must not do when a request fails. + if (refreshFailed) { + item { + Text( + text = stringResource(R.string.rust_refresh_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + items(servers, key = { it.id }) { server -> + ServerRow(server) { onOpenServer(server.id) } + } + } +} + +@Composable +private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) { + ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = server.name.ifBlank { server.id }, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + // `online` already has staleness folded into it server-side — a row + // nobody has written recently cannot claim a server is up — so this + // renders the field rather than second-guessing it. + if (server.online) { + StatusPill( + text = stringResource( + R.string.rust_online_count, + server.players, + server.maxPlayers, + ), + tone = PillTone.Success, + ) + } else { + StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral) + } + } + + val world = describeWorld(server) + if (world != null) { + Text( + text = world, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Text( + text = lastReported(server), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +/** + * The world line — the things a Rust player asks first. + * + * Null when the server has never described itself, so the caller leaves the line + * out rather than printing an empty one. A server configured this morning that + * has not connected yet is in exactly that state, and it is not an error. + */ +@Composable +internal fun describeWorld(server: RustServerDto): String? { + val parts = listOfNotNull( + server.level, + server.worldSize?.let { stringResource(R.string.rust_world_size, it) }, + server.seed?.let { stringResource(R.string.rust_world_seed, it) }, + wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) }, + ) + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +/** + * "last reported 3 minutes ago". + * + * **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact + * confusion and fixed it in phase 4: `updatedAt` moves on every poll including a + * failed one, so an offline server claimed it had just checked in, every thirty + * seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`. + */ +@Composable +internal fun lastReported(server: RustServerDto): String { + val ago = rustAgo(server.lastSeenAt) + ?: return stringResource(R.string.rust_never_reported) + + return if (server.stale) { + stringResource(R.string.rust_last_reported_stale, ago) + } else { + stringResource(R.string.rust_last_reported, ago) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersViewModel.kt new file mode 100644 index 0000000..92a86e3 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersViewModel.kt @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.Polled +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.refreshInto +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The Rust server list — the module's landing page, one tier along (PLAN.md §9 + * M14; `docs/modules/rust/PLAN.md` D12). + * + * **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature` + * gate, so a `404` here is a genuinely missing thing and never an admin's + * visibility switch. Offering "this shard doesn't publish it" for one would name + * a cause that does not exist on this surface. + * + * [refresh] is what the screen's poll calls and [load] is what a retry calls, and + * the difference is the whole of [refreshInto]: a refresh keeps the rows when it + * fails, a load is allowed to blank them because there is nothing on screen to + * protect. + */ +@HiltViewModel +class RustServersViewModel @Inject constructor( + private val repository: RustRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(Polled>()) + val state: StateFlow>> = _state.asStateFlow() + + /** A first load or a retry: show the spinner, then replace whatever comes back. */ + fun load() { + _state.value = Polled(UiState.Loading) + viewModelScope.launch { ask() } + } + + /** A poll: silent on success, and it keeps the rows on failure. */ + fun refresh() { + viewModelScope.launch { ask() } + } + + private suspend fun ask() { + _state.value = refreshInto(_state.value.state, repository.servers()) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ace8450..d2ca493 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Events Wiki Shard + Rust servers Rules Atlas Leaderboards @@ -566,4 +567,42 @@ Score %1$s Show more Loading… + + + No Rust servers are configured on this site yet. + Offline + %1$d / %2$d online + has never reported + last reported %1$s + last reported %1$s — out of date, so it is shown as offline + size %1$d + seed %1$d + wiped %1$s + Could not refresh just now. This is the last thing the site heard. + No such server + This address does not name a server this site follows. + Back to the server list + Feed + Leaderboard + Online + Wipes + All time + %1$s (this wipe) + Current + Showing + Nothing has happened on this server yet — or not during the wipe you are looking at. + Nobody has scored here yet. + This server has not reported a wipe yet. + The server is up and the island is empty. Somebody has to be first. + Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is. + On the server right now. + The last board this server sent. It is offline, so this is who was on then — not who is on now. + Sleeping + Player + Kills + Deaths + NPC + Built + Played + %1$d players online diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt new file mode 100644 index 0000000..93fcfa9 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt @@ -0,0 +1,97 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.fake + +import com.runicgateway.app.data.api.RustApi +import com.runicgateway.app.data.api.dto.RustEventListDto +import com.runicgateway.app.data.api.dto.RustLeaderboardDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.dto.RustServerResponse +import com.runicgateway.app.data.api.dto.RustWipeListDto + +/** + * A configurable fake of [RustApi] (M14). Set the `var` a call should answer + * with; set [error] to make every call throw. + * + * **The `last…` fields are what the tests that matter assert on.** The module's + * own API has one rule nothing about a successful response can show: an absent + * query parameter must be **absent** rather than empty, because `?wipe=` asks for + * a wipe whose id is the empty string and answers nothing, with no error to + * notice. Recording what was asked is the only way to see that from here. + */ +class FakeRustApi : RustApi { + + var error: Throwable? = null + + var servers: RustServerListDto = RustServerListDto() + var server: RustServerResponse = RustServerResponse() + var events: RustEventListDto = RustEventListDto() + var leaderboard: RustLeaderboardDto = RustLeaderboardDto() + var wipes: RustWipeListDto = RustWipeListDto() + var online: RustOnlineDto = RustOnlineDto() + + var serversCalls: Int = 0 + var eventCalls: Int = 0 + var leaderboardCalls: Int = 0 + var onlineCalls: Int = 0 + var wipeCalls: Int = 0 + + /** The `kind` the last feed read carried — null means it sent none at all. */ + var lastKind: String? = null + + /** The `wipe` the last feed read carried; null means all wipes. */ + var lastFeedWipe: String? = null + var lastLeaderboardWipe: String? = null + var lastSort: String? = null + var lastId: String? = null + + private fun reply(value: T): T { + error?.let { throw it } + return value + } + + override suspend fun getServers(): RustServerListDto { + serversCalls++ + return reply(servers) + } + + override suspend fun getServer(id: String): RustServerResponse { + lastId = id + return reply(server) + } + + override suspend fun getEvents(id: String, kind: String?, wipe: String?, limit: Int?): RustEventListDto { + eventCalls++ + lastId = id + lastKind = kind + lastFeedWipe = wipe + return reply(events) + } + + override suspend fun getLeaderboard( + id: String, + wipe: String?, + sort: String?, + limit: Int?, + ): RustLeaderboardDto { + leaderboardCalls++ + lastId = id + lastLeaderboardWipe = wipe + lastSort = sort + return reply(leaderboard) + } + + override suspend fun getWipes(id: String): RustWipeListDto { + wipeCalls++ + lastId = id + return reply(wipes) + } + + override suspend fun getOnline(id: String): RustOnlineDto { + onlineCalls++ + lastId = id + return reply(online) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/PollingTest.kt b/app/src/test/java/com/runicgateway/app/ui/PollingTest.kt new file mode 100644 index 0000000..290fb4a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/PollingTest.kt @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui + +import com.runicgateway.app.core.result.ApiResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rule a poll lives on (M14, `docs/modules/rust/PLAN.md` D14). + * + * **A refresh is invisible when it succeeds and keeps the rows when it fails.** + * The site's whole premise is that it renders while the game is off, and an app + * that blanked itself the first time a request failed would break that one tier + * along from where it was built. + */ +class PollingTest { + + @Test + fun `a successful refresh replaces the data and clears a previous failure`() { + val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(listOf("new"))) + + assertEquals(UiState.Success(listOf("new")), after.state) + assertFalse(after.refreshFailed) + } + + @Test + fun `a failed refresh keeps the rows and reports the failure`() { + // The case this whole file exists for. Nothing is blanked, nothing is + // retried on the reader's behalf, and the failure is a fact the screen can + // render beside rows that are still the best answer anybody has. + val before = UiState.Success(listOf("old")) + val after = refreshInto(before, ApiResult.NetworkError(RuntimeException("offline"))) + + assertEquals(before, after.state) + assertTrue(after.refreshFailed) + } + + @Test + fun `a failure with nothing on screen is an ordinary error`() { + // There is nothing to protect, so this is a first load that failed — and a + // screen that reported "could not refresh" over a blank page would be + // hiding the retry the reader needs. + val after = refreshInto(UiState.Loading, ApiResult.HttpError(500, "boom")) + + assertTrue(after.state is UiState.Error) + assertFalse(after.refreshFailed) + } + + @Test + fun `a 404 on a first load keeps its own kind`() { + val after = refreshInto(UiState.Loading, ApiResult.HttpError(404, "gone")) + + assertEquals(UiState.Error(ErrorKind.NOT_FOUND, 404), after.state) + } + + @Test + fun `a successful refresh over an error recovers`() { + val after = refreshInto( + UiState.Error(ErrorKind.NETWORK), + ApiResult.Ok(listOf("back")), + ) + + assertEquals(UiState.Success(listOf("back")), after.state) + assertFalse(after.refreshFailed) + } + + @Test + fun `an empty answer is a success, not a failure to keep the old rows through`() { + // An empty list is an ANSWER — the feed filtered to a wipe nothing happened + // in, a fleet with nobody on it. Treating it as "nothing came back" and + // keeping stale rows would make an emptied board impossible to observe. + val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(emptyList())) + + assertEquals(UiState.Success(emptyList()), after.state) + assertFalse(after.refreshFailed) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt index ace878d..d646a51 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt @@ -52,11 +52,17 @@ class NavOverridesTest { * showing through** (M13): About is core's last nav row at index 7 and the * module's nine append after it at 8-16. Under the stale sixteen-row table * About was index 15 and came last, which is what these assertions used to say. + * + * **The Rust row is last, at 17** (M14). These tests carry no capability + * answer, which fails open, so both game modules' rows appear here — a state + * no real backend is in and exactly the one this merge has to be correct for, + * since the sort key is the website's number line and not what happens to be + * installed. */ private val mergedPublic = listOf( Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"), Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, - Routes.SHARD_MARKET, + Routes.SHARD_MARKET, Routes.RUST, ) /** How many rows that block holds, so the take/drop below say why. */ @@ -154,13 +160,14 @@ class NavOverridesTest { // ── Order ──────────────────────────────────────────────────────────── @Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() { - // The website's own indices: About is 7, and Market — the last row of all, - // now that the module's nine append after core's eight — is 16. Dragging - // About to the top and Home past the end writes exactly this. + // The website's own indices: About is 7, Market is 16, and the Rust row — + // the last of all, now that a second game module's row appends after the + // first's nine — is 17. Dragging About to the top and Home past the end + // writes exactly this. val routes = routes( nav( "/site/about" to entry(order = 0), - "/" to entry(order = 17), + "/" to entry(order = 18), ), ) @@ -168,7 +175,7 @@ class NavOverridesTest { listOf( Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, - Routes.HOME, + Routes.RUST, Routes.HOME, ), routes.take(publicBlock), ) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt index 37c0d3e..c0d1218 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt @@ -19,12 +19,12 @@ import org.junit.Test class NavPathsTest { @Test fun everyWebsiteNavPathIsMapped() { - // Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If - // either side adds one, this is the test that says so — a path with no - // mapping is silently unresolvable in phase 6's link handling, which is - // exactly how the nine shard rows went stale for a month after the - // module-system cutover moved them from /site/ to /uo/ (M13). - assertEquals(17, WEBSITE_PUBLIC_NAV.size) + // Core's eight rows, module-uo's nine and module-rust's one, all quoted in + // NavPaths.kt. If any of the three adds one, this is the test that says so + // — a path with no mapping is silently unresolvable in phase 6's link + // handling, which is exactly how the nine shard rows went stale for a month + // after the module-system cutover moved them from /site/ to /uo/ (M13). + assertEquals(18, WEBSITE_PUBLIC_NAV.size) assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size) } @@ -61,9 +61,15 @@ class NavPathsTest { } @Test fun theDrawerRowsAreTheIntersectionWithAppMenu() { - // Ten of the seventeen have a drawer row. The other seven are mapped but not - // surfaced — three news category tabs and the four Shard hub boards — and - // an override for one of them is ignored rather than obeyed (§6.2). + // Eleven of the eighteen have a drawer row. The other seven are mapped but + // not surfaced — three news category tabs and the four Shard hub boards — + // and an override for one of them is ignored rather than obeyed (§6.2). + // + // **Both game modules appear here, and no backend serves both lists.** The + // table is a superset on purpose: a path for a module an operator has not + // installed never appears in that backend's nav and is never looked up, + // while a path MISSING from it makes a link that does exist hand off to a + // browser. val coded = APP_MENU.map { it.route }.toSet() val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path } @@ -71,6 +77,7 @@ class NavPathsTest { listOf( "/", "/site/news", "/site/events", "/wiki", "/site/about", "/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market", + "/rust", ), surfaced, ) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt index b60465c..ea14c76 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt @@ -166,10 +166,12 @@ class NavTreeTest { val shape = tree(row).shape() - // Nine public rows are left at the top level (Wiki moved into the section), - // then the section, then the app's own rows. - assertEquals("section:lore", shape[9]) - assertEquals(Routes.CONTACT, shape[10]) + // Ten public rows are left at the top level (Wiki moved into the section), + // then the section, then the app's own rows. The tenth is the Rust row: + // these tests carry no capability answer, which fails open, so both game + // modules' rows are present — see NavOverridesTest's `mergedPublic`. + assertEquals("section:lore", shape[10]) + assertEquals(Routes.CONTACT, shape[11]) } @Test fun aSectionsOrderPlacesItAmongTheCodedRows() { @@ -324,9 +326,9 @@ class NavTreeTest { val shape = tree(row).shape() assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") }) - // Market, not About: the module's nine rows append after core's eight on the - // website's number line, so Market is the last coded row rather than About. - assertEquals(Routes.SHARD_MARKET, shape[shape.indexOf("link:a") - 1]) + // The Rust row, not About: both modules' rows append after core's eight on + // the website's number line, and Rust's is last at 17. + assertEquals(Routes.RUST, shape[shape.indexOf("link:a") - 1]) } @Test fun aLinksOrderPlacesItAmongTheCodedRows() { 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 new file mode 100644 index 0000000..cce4a9b --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.data.repository.Capability +import com.runicgateway.app.data.repository.SiteCapabilities +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Rust row, its routes, and the two ways a Rust link can reach the app + * (M14, `docs/modules/rust/PLAN.md` §18). + * + * The question this file really asks is the phase criterion's other half: **a UO + * site is unchanged.** Two game modules can be installed on one backend, and + * neither one's rows may appear on a site running only the other. + */ +class RustNavigationTest { + + private fun serving(vararg caps: String) = + SiteCapabilities(core = emptySet(), modules = caps.toSet()) + + private fun routesFor(capabilities: SiteCapabilities?) = + visibleEntries(APP_MENU, Session.SignedOut, features = null, capabilities = capabilities) + .map { it.route } + + @Test fun aRustSiteShowsTheRustRowAndNoShardRows() { + val routes = routesFor(serving(Capability.RUST)) + + assertTrue(Routes.RUST in routes) + assertFalse("a Rust site has no shard", Routes.SHARD in routes) + assertFalse(Routes.ATLAS in routes) + } + + @Test fun aUoSiteIsExactlyAsItWas() { + // The other half of the criterion. Adding a second game module must not + // put a row on a site that does not run it. + val routes = routesFor(serving(Capability.SHARD)) + + assertFalse("a UO site has no Rust row", Routes.RUST in routes) + assertTrue(Routes.SHARD in routes) + } + + @Test fun bothModulesInstalledShowsBothTrees() { + val routes = routesFor(serving(Capability.SHARD, Capability.RUST)) + + assertTrue(Routes.SHARD in routes) + assertTrue(Routes.RUST in routes) + } + + @Test fun theSurfaceCapabilitiesDoNotRevealTheRow() { + // `module-rust` declares `servers`, `killfeed`, `leaderboard`, `presence` + // and `wipes` as well, and the app gates on NONE of them — every one names + // a surface, and core flattens all modules' capabilities into one list, so + // another module declaring `servers` would otherwise reveal these screens + // on a site with no Rust at all. + val routes = routesFor(serving("servers", "killfeed", "leaderboard", "presence", "wipes")) + + assertFalse(Routes.RUST in routes) + } + + @Test fun aHostThatHasNeverAnsweredStillShowsEverything() { + // Fail-open on an UNKNOWN answer, which is not the same as an empty one. + // The server gates every call regardless, so the cost of guessing wrong is + // a link that briefly 404s. + assertTrue(Routes.RUST in routesFor(null)) + } + + @Test fun aServerIdIsEncodedIntoItsRoute() { + assertEquals("rust/servers/main", Routes.rustServer("main")) + assertEquals("rust/servers/eu-main", Routes.rustServer("eu-main")) + // An operator names these, and nothing stops one carrying a character a + // path would otherwise eat. + assertEquals("rust/servers/a%2Fb", Routes.rustServer("a/b")) + assertEquals("rust/servers/two%20words", Routes.rustServer("two words")) + } + + @Test fun theWebsiteNavRowOpensNatively() { + // `module-rust` registers exactly one nav item, `{ label: 'Servers', to: + // '/rust' }`. Without this mapping an admin's nav override on that row — + // or an added link to it — hands off to a browser instead. + assertEquals(Routes.RUST, appRouteForWebPath("/rust")) + assertEquals(Routes.RUST, appRouteForWebPath("/rust/")) + } + + @Test fun anAddedLinkToOneServerResolves() { + assertEquals(Routes.rustServer("main"), resolveWebPath("/rust/servers/main")) + } + + @Test fun theRustPrefixIsNotMistakenForACmsPage() { + // The site serves CMS pages from a top-level `/`, and `/rust` would + // otherwise fall into that rule and open a page-not-found screen. It is + // reserved so the nav table above answers it — and so that a site WITHOUT + // the module hands off to the browser, which gives the same answer the web + // would. + assertEquals(Routes.RUST, resolveWebPath("/rust")) + assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra")) + } + + @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 + // what the admin wrote, so it goes to the browser, which honors it. + assertNull(resolveWebPath("/rust?tab=wipes")) + assertNull(resolveWebPath("/rust/servers/main?wipe=w1")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustBadgeViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustBadgeViewModelTest.kt new file mode 100644 index 0000000..96067aa --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustBadgeViewModelTest.kt @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.fake.FakeRustApi +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.util.MainDispatcherRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** The drawer row's live count — the phone's answer to D15 (M14). */ +class RustBadgeViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val api = FakeRustApi() + private fun viewModel() = RustBadgeViewModel(RustRepository(api)) + + @Test + fun `it sums the people on every server`() { + api.servers = RustServerListDto( + listOf( + RustServerDto(id = "a", online = true, players = 12), + RustServerDto(id = "b", online = true, players = 30), + ), + ) + + val vm = viewModel() + vm.refresh(installed = true) + + assertEquals(42, vm.online.value) + } + + @Test + fun `a site without the module is never asked`() { + // The gate is the caller's — the drawer already knows, from the capability + // answer. A site running a different game makes no request at all. + val vm = viewModel() + vm.refresh(installed = false) + + assertEquals(0, api.serversCalls) + assertEquals(0, vm.online.value) + } + + @Test + fun `an unreachable server contributes nothing on its own`() { + // A stale or offline row already answers `online: false` with `players: 0` + // server-side, so there is no second staleness rule to keep in step here. + api.servers = RustServerListDto( + listOf( + RustServerDto(id = "a", online = true, players = 5), + RustServerDto(id = "b", online = false, players = 0, stale = true), + ), + ) + + val vm = viewModel() + vm.refresh(installed = true) + + assertEquals(5, vm.online.value) + } + + @Test + fun `a failed read keeps the last number rather than dropping to zero`() { + // A moment with no connectivity is not everybody logging off. + api.servers = RustServerListDto(listOf(RustServerDto(id = "a", online = true, players = 7))) + val vm = viewModel() + vm.refresh(installed = true) + + api.error = IOException("offline") + vm.refresh(installed = true) + + assertEquals(7, vm.online.value) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt new file mode 100644 index 0000000..177f058 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustServerListDto +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 + +/** + * Decoding what the module actually answers with (M14). + * + * The JSON here is copied from `module-rust`'s own shape functions rather than + * invented, because the only thing worth testing about a DTO is whether it agrees + * with the other end. + */ +class RustDtoTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `an unreachable server decodes with everything it last said`() { + // The phase criterion in one object: `online: false`, `stale: true`, and + // every descriptive field still populated. This is what makes a page that + // renders while the game is off possible at all. + val list = json.decodeFromString( + """ + {"servers":[{ + "id":"main","name":"Main","online":false,"players":0,"maxPlayers":100, + "hostname":"Main | Vanilla","level":"Procedural Map","worldSize":4000, + "seed":1934567,"wipeId":"w-2026-09-04","wipedAt":"2026-09-04T18:00:00.000Z", + "lastSeenAt":"2026-09-14T10:12:00.000Z","updatedAt":"2026-09-16T13:59:30.000Z", + "stale":true + }]} + """.trimIndent(), + ) + + val server = list.servers.single() + assertFalse(server.online) + assertTrue(server.stale) + assertEquals("Procedural Map", server.level) + assertEquals(4000, server.worldSize) + // The two timestamps are two facts and both survive the wire. + assertEquals("2026-09-14T10:12:00.000Z", server.lastSeenAt) + assertEquals("2026-09-16T13:59:30.000Z", server.updatedAt) + } + + @Test + fun `a server that has never connected decodes with nulls, not zeroes`() { + val list = json.decodeFromString( + """{"servers":[{"id":"new","name":"New","online":false,"players":0,"maxPlayers":0, + "hostname":null,"level":null,"worldSize":null,"seed":null,"wipeId":null, + "wipedAt":null,"lastSeenAt":null,"updatedAt":null,"stale":true}]}""", + ) + + val server = list.servers.single() + assertNull(server.level) + assertNull(server.worldSize) + assertNull(server.lastSeenAt) + } + + @Test + fun `an unknown field does not break decoding`() { + // Protocol 2 is not the last one. A later plugin adds fields to a frame's + // envelope and an older app must keep reading the rest. + val list = json.decodeFromString( + """{"servers":[{"id":"main","name":"Main","somethingNew":{"a":1}}],"alsoNew":7}""", + ) + + assertEquals("main", list.servers.single().id) + } + + @Test + fun `a frame keeps whatever the plugin wrote`() { + val event = json.decodeFromString( + """{"id":9,"kind":"player.death","t":1789574400000,"wipeId":"w1","steamId":"765", + "frame":{"name":"Bob","attackerType":"player","attackerName":"Alice","distance":42.4}}""", + ) + + assertEquals("player.death", event.kind) + assertEquals(1789574400000L, event.t) + assertEquals("Alice", event.str("attackerName")) + assertEquals(42.4, event.num("distance")!!, 0.001) + assertNull("an absent field is null, not empty", event.str("weapon")) + assertFalse(event.flag("sleeping")) + } + + @Test + fun `a frame with no fields at all still decodes`() { + // `server.initialized` carries an envelope and nothing else, and the model + // answers an EMPTY frame for a stored row whose JSON will not parse — + // deliberately, so one bad row does not fail a whole page. + val event = json.decodeFromString( + """{"id":1,"kind":"server.initialized","t":1,"frame":{}}""", + ) + + assertNull(event.str("name")) + assertEquals(FeedTone.SERVER, describe(event).tone) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt new file mode 100644 index 0000000..35e79ed --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustEventDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What a feed row says (M14). + * + * The frames here are the shapes the bridge plugin actually emits, written as + * JSON rather than built with a DTO constructor — the whole point of the untyped + * `frame` is that the app reads names off the wire, and a test that bypassed the + * parse would prove nothing about the names. + */ +class RustFeedTest { + + private fun row(kind: String, frame: String = "{}", t: Long = 1_000): RustEventDto = + RustEventDto(id = 1, kind = kind, t = t, frame = Json.parseToJsonElement(frame) as JsonObject) + + @Test + fun `a player kill names the killer, the victim and the weapon`() { + val line = describe( + row( + "player.death", + """{"name":"Bob","attackerType":"player","attackerName":"Alice","weapon":"rifle.ak","distance":42.4}""", + ), + ) + + assertEquals(FeedTone.KILL, line.tone) + assertEquals("Alice", line.actor) + assertEquals("killed", line.verb) + assertEquals("Bob", line.subject) + assertTrue(line.detail.contains("with rifle ak")) + assertTrue(line.detail.contains("42m")) + } + + @Test + fun `a fall is a death by nobody, not a kill`() { + // The failure this distinction exists to avoid: `HitInfo` is legitimately + // null when the world kills somebody, so an ABSENT attacker type is the + // environment case rather than a missing field. Reporting it as a kill by + // nobody is the bug. + val line = describe(row("player.death", """{"name":"Bob"}""")) + + assertEquals(FeedTone.DEATH, line.tone) + assertEquals("Bob", line.actor) + assertEquals("died", line.verb) + assertNull(line.subject) + } + + @Test + fun `an NPC kill reads the prefab as words`() { + val line = describe( + row("player.death", """{"name":"Bob","attackerType":"npc","attackerName":"patrolhelicopter"}"""), + ) + + assertEquals("patrolhelicopter", line.actor) + assertEquals("Bob", line.subject) + } + + @Test + fun `a suicide names one person once`() { + val line = describe(row("player.death", """{"name":"Bob","attackerType":"self"}""")) + + assertEquals("Bob", line.actor) + assertNull(line.subject) + } + + @Test + fun `chat puts the colon in the join, never in the message`() { + val line = describe(row("player.chat", """{"name":"Bob","message":"see you in september"}""")) + + assertEquals(": ", line.join) + assertEquals("see you in september", line.verb) + } + + @Test + fun `a disconnect with no session reports only the reason`() { + // The plugin OMITS `sessionSec` for a player who was already connected when + // it loaded, so an absent value means "unknown" and must not become "after + // 0s" — which is what a DTO default of zero would produce if it were read + // without this guard. + val line = describe(row("player.disconnected", """{"name":"Bob","reason":"Disconnected"}""")) + + assertEquals("Disconnected", line.detail) + } + + @Test + fun `a JSON null field does not become the word null`() { + // A primitive's `content` is literally "null" for a JSON null, and the + // plugin writes explicit nulls — so a naive read puts the four letters + // into a killfeed line. + val line = describe(row("player.disconnected", """{"name":"Bob","reason":null,"sessionSec":null}""")) + + assertEquals("", line.detail) + } + + @Test + fun `an unknown kind renders as itself rather than vanishing`() { + // A later protocol adds kinds and an operator's module may be older than + // their game host. The server's allowlist has already decided the row may + // be seen; dropping it here would be the screen quietly saying less than + // the truth. + val line = describe(row("player.teleported", """{"name":"Bob"}""")) + + assertEquals(FeedTone.OTHER, line.tone) + assertEquals("player.teleported", line.verb) + assertEquals("Bob", line.actor) + } + + @Test + fun `the tally kind is not in the feed's own list`() { + // It is an aggregate the plugin flushes every sixty seconds per active + // player, so a feed carrying it would be mostly wood counts. It is the + // leaderboard's input. + assertTrue("player.tally" !in FEED_KINDS) + } + + @Test + fun `every filter asks for kinds the feed knows`() { + for (filter in FEED_FILTERS) { + assertTrue( + "${filter.id} asks for a kind the feed does not list", + FEED_KINDS.containsAll(filter.kinds), + ) + } + } + + @Test + fun `an unknown filter id falls back to everything`() { + assertEquals(FEED_KINDS, kindsFor("nonsense")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt new file mode 100644 index 0000000..1f0525c --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -0,0 +1,134 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +/** Formatting rules the Rust screens depend on (M14). */ +class RustFormatTest { + + private val utc = ZoneId.of("UTC") + private val uk = Locale.UK + private val now = Instant.parse("2026-09-16T14:00:00Z") + + @Test + fun `a row from today is a bare time`() { + val stamp = Instant.parse("2026-09-16T09:05:00Z").toEpochMilli() + val text = feedClock(value = null, epochMillis = stamp, now = now, zone = utc, locale = uk) + + assertTrue(text, ':' in text) + assertTrue("today's row should carry no date: $text", "Sep" !in text) + } + + @Test + fun `a row from another day carries its date`() { + // The defect this rule exists for: the feed can be filtered to a past + // wipe, and three events from six weeks ago all rendered as `02:03 PM` + // read as this afternoon. The boundary is the CALENDAR day, not a + // duration, because that is what a reader means by "what time was that". + val stamp = Instant.parse("2026-08-05T09:05:00Z").toEpochMilli() + val text = feedClock(value = null, epochMillis = stamp, now = now, zone = utc, locale = uk) + + assertTrue("an older row should carry a date: $text", "Aug" in text) + } + + @Test + fun `yesterday is another day even when it is minutes ago`() { + val justBeforeMidnight = Instant.parse("2026-09-15T23:58:00Z").toEpochMilli() + val shortlyAfter = Instant.parse("2026-09-16T00:02:00Z") + val text = feedClock( + value = null, + epochMillis = justBeforeMidnight, + now = shortlyAfter, + zone = utc, + locale = uk, + ) + + assertTrue("four minutes ago but a different day: $text", "Sep" in text) + } + + @Test + fun `an unparseable stamp is empty rather than a guess`() { + assertEquals("", feedClock(value = null, epochMillis = null, now = now, zone = utc, locale = uk)) + assertEquals("", feedClock(value = "not a date", now = now, zone = utc, locale = uk)) + } + + @Test + fun `a zoneless DATETIME is read as UTC`() { + // Express serializes a Date to ISO with a `Z`, but these values start life + // as MariaDB DATETIME columns and one read back as a string reaches the + // wire with no zone at all. Reading it as local time silently shifts every + // timestamp by the device's offset — a bug that looks right on the machine + // it was written on. + val text = feedClock( + value = "2026-08-05 09:05:00", + now = now, + zone = utc, + locale = uk, + ) + + assertTrue(text, "Aug" in text) + assertTrue(text, "09:05" in text) + } + + @Test + fun `a server that has never reported has no ago line at all`() { + // Null rather than the word "never", so the caller decides what that looks + // like — on this surface a server that has never reported is a real and + // ordinary state, not a missing value to apologise for. + assertNull(rustAgo(null, now)) + assertNull(rustAgo("", now)) + } + + @Test + fun `under a minute says just now rather than in zero seconds`() { + assertEquals("just now", rustAgo("2026-09-16T13:59:40Z", now)) + } + + @Test + fun `ago picks the largest unit that fits`() { + assertEquals("3 minutes ago", rustAgo("2026-09-16T13:57:00Z", now)) + assertEquals("2 hours ago", rustAgo("2026-09-16T12:00:00Z", now)) + assertEquals("1 day ago", rustAgo("2026-09-15T14:00:00Z", now)) + } + + @Test + fun `playtime drops seconds above a minute and keeps them below`() { + assertEquals("—", playtime(null)) + assertEquals("—", playtime(0)) + assertEquals("40s", playtime(40)) + assertEquals("12m", playtime(12 * 60)) + assertEquals("4h 12m", playtime(4 * 3600 + 12 * 60)) + assertEquals("2h", playtime(2 * 3600)) + } + + @Test + fun `a prefab reads as words without a lookup table`() { + assertEquals("rifle ak", prefabName("rifle.ak")) + assertEquals("patrolhelicopter", prefabName("patrolhelicopter")) + assertEquals("", prefabName(null)) + } + + @Test + fun `a nameless player shows a shortened id, not the word unknown`() { + // A steam id is not a name and does not look like one, which is the point: + // the plugin knows an id before it knows anything else, and "Unknown" would + // lose the only identifier there is. + assertEquals("…345678", playerLabel(null, "76561198012345678")) + assertEquals("Bob", playerLabel("Bob", "76561198012345678")) + assertEquals("…345678", playerLabel(" ", "76561198012345678")) + } + + @Test + fun `a short id is left whole`() { + assertEquals("1234", shortSteamId("1234")) + assertEquals("", shortSteamId(null)) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt new file mode 100644 index 0000000..efec390 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt @@ -0,0 +1,193 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.SavedStateHandle +import com.runicgateway.app.data.api.dto.RustLeaderboardDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustPresenceDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustServerResponse +import com.runicgateway.app.data.api.dto.RustWipeDto +import com.runicgateway.app.data.api.dto.RustWipeListDto +import com.runicgateway.app.data.api.fake.FakeRustApi +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +/** One server's page: what it asks for, and when (M14). */ +class RustServerViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val api = FakeRustApi() + private val repository = RustRepository(api) + + private fun viewModel(id: String = "main") = RustServerViewModel( + repository, + SavedStateHandle(mapOf("serverId" to id)), + ) + + @Test + fun `it opens on the feed and asks for nothing else`() { + api.server = RustServerResponse(RustServerDto(id = "main", name = "Main")) + + val vm = viewModel() + + assertEquals(RustTab.FEED, vm.state.value.tab) + assertEquals(1, api.eventCalls) + assertEquals(0, api.leaderboardCalls) + assertEquals(0, api.onlineCalls) + assertEquals(0, api.wipeCalls) + } + + @Test + fun `all time sends no wipe parameter at all`() { + // `?wipe=` asks for a wipe whose id is the empty string and answers + // nothing, with no error to notice. An absent parameter must be ABSENT. + viewModel() + + assertNull(api.lastFeedWipe) + } + + @Test + fun `everything sends the whole kind list, and kills sends one`() { + val vm = viewModel() + assertEquals(FEED_KINDS.joinToString(","), api.lastKind) + + vm.selectFilter("kills") + assertEquals("player.death", api.lastKind) + } + + @Test + fun `a tab is loaded once, not on every visit`() { + // Re-asking on every tab switch would put a spinner over a leaderboard the + // reader has already read, for an answer that cannot have changed while + // they were three taps away. + api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1", kills = 3))) + val vm = viewModel() + + vm.selectTab(RustTab.LEADERBOARD) + vm.selectTab(RustTab.FEED) + vm.selectTab(RustTab.LEADERBOARD) + + assertEquals(1, api.leaderboardCalls) + } + + @Test + fun `the poll asks only for the panel on screen`() { + api.online = RustOnlineDto(listOf(RustPresenceDto(steamId = "1"))) + val vm = viewModel() + val feedBefore = api.eventCalls + + vm.selectTab(RustTab.ONLINE) + val onlineBefore = api.onlineCalls + vm.refresh() + + assertEquals("the feed is not on screen", feedBefore, api.eventCalls) + assertEquals(onlineBefore + 1, api.onlineCalls) + } + + @Test + fun `a poll on a still panel asks for nothing but the server line`() { + api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1"))) + val vm = viewModel() + + vm.selectTab(RustTab.WIPES) + val wipesBefore = api.wipeCalls + val feedBefore = api.eventCalls + vm.refresh() + + assertEquals(wipesBefore, api.wipeCalls) + assertEquals(feedBefore, api.eventCalls) + } + + @Test + fun `choosing a wipe re-asks the feed and, if it is loaded, the leaderboard`() { + api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1"))) + val vm = viewModel() + + vm.selectWipe("wipe-1") + assertEquals("wipe-1", api.lastFeedWipe) + // Nobody has opened the leaderboard, so nothing was fetched for it. + assertEquals(0, api.leaderboardCalls) + + vm.selectTab(RustTab.LEADERBOARD) + vm.selectWipe("wipe-2") + assertEquals("wipe-2", api.lastLeaderboardWipe) + assertEquals("wipe-2", api.lastFeedWipe) + } + + @Test + fun `picking the same wipe twice asks nothing`() { + val vm = viewModel() + vm.selectWipe("wipe-1") + val calls = api.eventCalls + + vm.selectWipe("wipe-1") + + assertEquals(calls, api.eventCalls) + } + + @Test + fun `opening a wipe from the wipes tab lands on the feed`() { + // Picking a wipe there is a navigation as much as a filter: the question is + // "what happened during that map", and the answer is the feed. + val vm = viewModel() + vm.selectTab(RustTab.WIPES) + + vm.openWipe("wipe-1") + + assertEquals(RustTab.FEED, vm.state.value.tab) + assertEquals("wipe-1", vm.state.value.selectedWipe) + } + + @Test + fun `a 404 on the server read is its own state, not a generic error`() { + // A mistyped address is not a fault. The screen reads NOT_FOUND and says + // "no such server" rather than dressing it as an outage. + api.error = HttpException(Response.error(404, "".toResponseBody(null))) + + val vm = viewModel("typo") + + val state = vm.state.value.server.state + assertTrue(state is UiState.Error) + assertEquals(404, (state as UiState.Error).httpStatus) + } + + @Test + fun `a failed poll keeps the server line that was there`() { + api.server = RustServerResponse(RustServerDto(id = "main", name = "Main")) + val vm = viewModel() + + api.error = IOException("offline") + vm.refresh() + + val state = vm.state.value.server.state + assertTrue(state is UiState.Success) + assertEquals("Main", (state as UiState.Success).data.name) + assertTrue(vm.state.value.server.refreshFailed) + } + + @Test + fun `sorting sends the API's own vocabulary`() { + val vm = viewModel() + vm.selectTab(RustTab.LEADERBOARD) + + vm.selectSort(RustSort.PLAYTIME) + + assertEquals("playtime", api.lastSort) + } +}