Compare commits
5 Commits
ac2d75c3f9
...
37a828736e
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 | |||
| a6677d5bf9 | |||
| a6b6c92c33 |
@@ -45,8 +45,15 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# `packages: ''` is load-bearing, not tidying. The action's own default is
|
||||
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
|
||||
# default makes `sdkmanager tools` exit 1 and the step fails before a line
|
||||
# of this repo is compiled. It is redundant here regardless: the next step
|
||||
# installs exactly what the build targets.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
# Install exactly what the build targets so it never depends on AGP's
|
||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||
|
||||
94
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
94
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
@@ -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 `/<module id>/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
|
||||
}
|
||||
196
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
196
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
@@ -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<RustServerDto> = 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<RustEventDto> = 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<RustLeaderboardRowDto> = 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<RustWipeDto> = 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<RustPresenceDto> = 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,
|
||||
)
|
||||
@@ -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<List<RustServerDto>> =
|
||||
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<RustServerDto> =
|
||||
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<String> = emptyList(),
|
||||
wipe: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustEventDto>> = 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<List<RustLeaderboardRowDto>> = 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<List<RustWipeDto>> =
|
||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||
|
||||
/** The presence board. Rows survive an unreachable server, by design. */
|
||||
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
|
||||
safeApiCall { api.getOnline(id) }.map { it.players }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
@@ -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<out T>(
|
||||
val state: UiState<T> = 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 <T> refreshInto(current: UiState<T>, result: ApiResult<T>): Polled<T> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -154,15 +162,29 @@ fun RunicApp(
|
||||
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3),
|
||||
// and re-read the unread count with it: a tickle that arrived while the app was
|
||||
// away is exactly what brings someone back to it.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
// and re-read the two drawer counts with it: a tickle that arrived while the app
|
||||
// was away is exactly what brings someone back to it, and a live player count is
|
||||
// only live if it is re-read when somebody looks.
|
||||
LifecycleResumeEffect(capabilities) {
|
||||
sessionViewModel.revalidate()
|
||||
inboxBadgeViewModel.refresh()
|
||||
// The Rust count is a LIVE number, so it is re-read on the same clock the
|
||||
// unread badge is: coming back to the app is exactly when a stale one
|
||||
// would be noticed. Keyed on the capability answer as well as on resume,
|
||||
// because the very first resume happens before this host has said whether
|
||||
// the module is there — and asking then would either make a request on a
|
||||
// site that has no Rust, or never make one at all.
|
||||
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
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 on resume, never on a
|
||||
// timer: a badge is a glance, not a feed. Gated here rather than inside the
|
||||
// view model because this is the only place that knows whether the module is
|
||||
// installed at all, and a host that has not answered yet asks nothing.
|
||||
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
|
||||
// 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 +289,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 +415,7 @@ private fun NavRow(
|
||||
colors: NavigationDrawerItemColors,
|
||||
indented: Boolean = false,
|
||||
unread: Int = 0,
|
||||
rustOnline: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val route = when (node) {
|
||||
@@ -405,6 +435,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 +471,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 +625,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)) })
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ val APP_MENU: List<MenuEntry> = 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),
|
||||
|
||||
@@ -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<WebNavPath> = 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 `/<id>` 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
|
||||
* <Route path="/:slug" element={<CmsPage />} />
|
||||
* ```
|
||||
@@ -231,6 +256,8 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* /uo/<shard surface> → the mapped shard route (§6.2)
|
||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /rust → RUST (module-rust's server list)
|
||||
* /rust/servers/<id> → RUST_SERVER
|
||||
* /site/about → PAGE("about")
|
||||
* /<slug> → PAGE(slug), unless <slug> 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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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<Int> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
@@ -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<String>)
|
||||
|
||||
/**
|
||||
* 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<String> = 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<FeedFilter> = 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<String> =
|
||||
(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)
|
||||
}
|
||||
}
|
||||
154
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
154
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
@@ -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)
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* 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.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.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<List<RustWipeDto>>,
|
||||
) {
|
||||
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<RustWipeDto>,
|
||||
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<List<RustEventDto>>,
|
||||
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)
|
||||
|
||||
/** The name's share of the row against one numeric column's. */
|
||||
private const val NAME_WEIGHT = 1.7f
|
||||
|
||||
/** How far a header that is not the current sort is faded. */
|
||||
private const val SORTED_AWAY = 0.55f
|
||||
|
||||
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<List<RustLeaderboardRowDto>>,
|
||||
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(
|
||||
text = stringResource(R.string.rust_col_player),
|
||||
modifier = Modifier.weight(NAME_WEIGHT),
|
||||
)
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
// The ACTIVE sort is marked on the header, not on the
|
||||
// values: the header is the control, and tinting a
|
||||
// column of numbers instead says "these are special"
|
||||
// rather than "this is what the table is ordered by".
|
||||
SectionLabel(
|
||||
text = stringResource(column.labelRes),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (column.sort != null) {
|
||||
Modifier.clickable { onSort(column.sort) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (column.sort == sort) {
|
||||
Modifier.alpha(1f)
|
||||
} else {
|
||||
Modifier.alpha(SORTED_AWAY)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.steamId }) { row ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// A wider share for the name, and one line with an ellipsis.
|
||||
// Five numeric columns beside an equal-weight name column
|
||||
// left "Brannock" touching its own kill count, which the
|
||||
// walk read as one field.
|
||||
Text(
|
||||
text = playerLabel(row.name, row.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
|
||||
)
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
Text(
|
||||
text = column.value(row),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun OnlinePanel(
|
||||
online: Polled<List<RustPresenceDto>>,
|
||||
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().padding(16.dp),
|
||||
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<List<RustWipeDto>>,
|
||||
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().padding(16.dp),
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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<RustServerDto> = 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<List<RustEventDto>> = Polled(),
|
||||
val online: Polled<List<RustPresenceDto>> = Polled(),
|
||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||
val wipes: UiState<List<RustWipeDto>> = 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<RustServerUi> = _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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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<RustServerDto>,
|
||||
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)) {
|
||||
// `ShardCard` is the themed Card and nothing more — it carries no padding
|
||||
// of its own, so every caller pads its own content. Without this the text
|
||||
// sits flush against the card's edge and the first glyph of each line
|
||||
// reads as clipped, which is what the walk saw.
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<List<RustServerDto>>())
|
||||
val state: StateFlow<Polled<List<RustServerDto>>> = _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())
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
<string name="menu_events">Events</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_rust">Rust servers</string>
|
||||
<string name="menu_rules">Rules</string>
|
||||
<string name="menu_atlas">Atlas</string>
|
||||
<string name="menu_leaderboards">Leaderboards</string>
|
||||
@@ -566,4 +567,42 @@
|
||||
<string name="events_score">Score %1$s</string>
|
||||
<string name="events_show_more">Show more</string>
|
||||
<string name="events_loading">Loading…</string>
|
||||
|
||||
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
|
||||
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
|
||||
<string name="rust_offline">Offline</string>
|
||||
<string name="rust_online_count">%1$d / %2$d online</string>
|
||||
<string name="rust_never_reported">has never reported</string>
|
||||
<string name="rust_last_reported">last reported %1$s</string>
|
||||
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
|
||||
<string name="rust_world_size">size %1$d</string>
|
||||
<string name="rust_world_seed">seed %1$d</string>
|
||||
<string name="rust_wiped_on">wiped %1$s</string>
|
||||
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
||||
<string name="rust_no_such_server">No such server</string>
|
||||
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
||||
<string name="rust_back_to_servers">Back to the server list</string>
|
||||
<string name="rust_tab_feed">Feed</string>
|
||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||
<string name="rust_tab_online">Online</string>
|
||||
<string name="rust_tab_wipes">Wipes</string>
|
||||
<string name="rust_all_time">All time</string>
|
||||
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||
<string name="rust_wipe_this_one">Current</string>
|
||||
<string name="rust_wipe_selected">Showing</string>
|
||||
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||
<string name="rust_presence_live">On the server right now.</string>
|
||||
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
|
||||
<string name="rust_sleeping">Sleeping</string>
|
||||
<string name="rust_col_player">Player</string>
|
||||
<string name="rust_col_kills">Kills</string>
|
||||
<string name="rust_col_deaths">Deaths</string>
|
||||
<string name="rust_col_npc_kills">NPC</string>
|
||||
<string name="rust_col_structures">Built</string>
|
||||
<string name="rust_col_played">Played</string>
|
||||
<string name="rust_online_badge">%1$d players online</string>
|
||||
</resources>
|
||||
|
||||
@@ -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 <T> 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)
|
||||
}
|
||||
}
|
||||
81
app/src/test/java/com/runicgateway/app/ui/PollingTest.kt
Normal file
81
app/src/test/java/com/runicgateway/app/ui/PollingTest.kt
Normal file
@@ -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<String>()))
|
||||
|
||||
assertEquals(UiState.Success(emptyList<String>()), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 `/<slug>`, 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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
105
app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
Normal file
105
app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
Normal file
@@ -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<RustServerListDto>(
|
||||
"""
|
||||
{"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<RustServerListDto>(
|
||||
"""{"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<RustServerListDto>(
|
||||
"""{"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<RustEventDto>(
|
||||
"""{"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<RustEventDto>(
|
||||
"""{"id":1,"kind":"server.initialized","t":1,"frame":{}}""",
|
||||
)
|
||||
|
||||
assertNull(event.str("name"))
|
||||
assertEquals(FeedTone.SERVER, describe(event).tone)
|
||||
}
|
||||
}
|
||||
140
app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt
Normal file
140
app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt
Normal file
@@ -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"))
|
||||
}
|
||||
}
|
||||
134
app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
Normal file
134
app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<Any>(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user