feat(rust): the Rust server list and one server's page — M14 (module-rust phase 5, Android leg A) #47
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"
|
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. */
|
/** Core's event system (events Phase 14a). Never a module's. */
|
||||||
const val EVENTS = "events"
|
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.NotificationsApi
|
||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.RustApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -132,6 +133,19 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
|
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. */
|
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@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.core.web.WebHandoff
|
||||||
import com.runicgateway.app.data.api.dto.BrandDto
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
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.AccountScreen
|
||||||
import com.runicgateway.app.ui.auth.LoginScreen
|
import com.runicgateway.app.ui.auth.LoginScreen
|
||||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
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.MarketVendorScreen
|
||||||
import com.runicgateway.app.ui.shard.RulesScreen
|
import com.runicgateway.app.ui.shard.RulesScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardBoard
|
import com.runicgateway.app.ui.shard.ShardBoard
|
||||||
|
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
||||||
|
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||||
|
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardScreen
|
import com.runicgateway.app.ui.shard.ShardScreen
|
||||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
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
|
// 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".
|
// deliberately absent — a back gesture there means "back", not "open the menu".
|
||||||
Routes.EVENTS, Routes.MY_EVENTS,
|
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.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
@@ -140,6 +147,7 @@ fun RunicApp(
|
|||||||
onDeepLinkConsumed: () -> Unit = {},
|
onDeepLinkConsumed: () -> Unit = {},
|
||||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||||
|
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val brand = appearance.brand
|
val brand = appearance.brand
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@@ -163,6 +171,17 @@ fun RunicApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
|
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
|
||||||
|
// How many people are on the Rust fleet, for the drawer row's badge — the
|
||||||
|
// phone's answer to D15's footer count (M14). Refreshed with the capability
|
||||||
|
// answer rather than on a timer: a badge is a glance, not a feed, and this is
|
||||||
|
// the only place that knows whether the module is installed at all. A host
|
||||||
|
// that has not answered yet asks nothing, so a cold start makes no request
|
||||||
|
// until it knows there is something to ask about.
|
||||||
|
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(capabilities) {
|
||||||
|
val caps = capabilities
|
||||||
|
if (caps != null) rustBadgeViewModel.refresh(Capability.RUST in caps)
|
||||||
|
}
|
||||||
// The badge follows the session, so signing out clears it rather than leaving
|
// The badge follows the session, so signing out clears it rather than leaving
|
||||||
// the previous account's count on the drawer.
|
// the previous account's count on the drawer.
|
||||||
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
||||||
@@ -267,10 +286,17 @@ fun RunicApp(
|
|||||||
colors = drawerItemColors,
|
colors = drawerItemColors,
|
||||||
indented = true,
|
indented = true,
|
||||||
unread = unread,
|
unread = unread,
|
||||||
|
rustOnline = rustOnline,
|
||||||
) { openNode(child) }
|
) { openNode(child) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
|
NavRow(
|
||||||
|
node = node,
|
||||||
|
currentRoute = currentRoute,
|
||||||
|
colors = drawerItemColors,
|
||||||
|
unread = unread,
|
||||||
|
rustOnline = rustOnline,
|
||||||
|
) { openNode(node) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +412,7 @@ private fun NavRow(
|
|||||||
colors: NavigationDrawerItemColors,
|
colors: NavigationDrawerItemColors,
|
||||||
indented: Boolean = false,
|
indented: Boolean = false,
|
||||||
unread: Int = 0,
|
unread: Int = 0,
|
||||||
|
rustOnline: Int = 0,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val route = when (node) {
|
val route = when (node) {
|
||||||
@@ -405,6 +432,15 @@ private fun NavRow(
|
|||||||
// admin's own nav override pointing at it, since the badge belongs to the
|
// admin's own nav override pointing at it, since the badge belongs to the
|
||||||
// destination, not to the bundled entry.
|
// destination, not to the bundled entry.
|
||||||
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
|
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(
|
NavigationDrawerItem(
|
||||||
label = { Text(label) },
|
label = { Text(label) },
|
||||||
@@ -432,6 +468,19 @@ private fun NavRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
showsRustOnline -> {
|
||||||
|
{
|
||||||
|
// Named for a screen reader: "42" beside "Rust servers" reads
|
||||||
|
// as a count to a sighted user and as a bare number to
|
||||||
|
// everyone else.
|
||||||
|
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
|
||||||
|
Text(
|
||||||
|
text = rustOnline.toString(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.semantics { contentDescription = spoken },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
colors = colors,
|
colors = colors,
|
||||||
@@ -573,6 +622,17 @@ private fun RunicNavHost(
|
|||||||
) { entry ->
|
) { entry ->
|
||||||
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
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) {
|
composable(Routes.WIKI) {
|
||||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
feature = ShardFeature.MARKET,
|
feature = ShardFeature.MARKET,
|
||||||
capability = Capability.SHARD,
|
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.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
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
|
* 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.
|
* 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/atlas", Routes.ATLAS),
|
||||||
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||||
WebNavPath("/uo/market", Routes.SHARD_MARKET),
|
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
|
// 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
|
// 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.
|
// 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/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
|
||||||
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
||||||
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
|
* // /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
|
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||||
* <Route path="/:slug" element={<CmsPage />} />
|
* <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/<shard surface> → the mapped shard route (§6.2)
|
||||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||||
|
* /rust → RUST (module-rust's server list)
|
||||||
|
* /rust/servers/<id> → RUST_SERVER
|
||||||
* /site/about → PAGE("about")
|
* /site/about → PAGE("about")
|
||||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||||
* anything else → null, i.e. the Custom Tab
|
* anything else → null, i.e. the Custom Tab
|
||||||
@@ -295,6 +322,10 @@ fun resolveWebPath(path: String?): String? {
|
|||||||
Routes.atlasCreature(segments[2])
|
Routes.atlasCreature(segments[2])
|
||||||
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
|
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
|
||||||
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
|
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
|
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.
|
* module is countable. It is a literal on purpose — see the file header.
|
||||||
*/
|
*/
|
||||||
private const val MODULE_UO = "uo"
|
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 EVENT_SERIES = "events/series/{slug}"
|
||||||
const val MY_EVENTS = "account/events"
|
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). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
|
|
||||||
@@ -121,6 +137,7 @@ object Routes {
|
|||||||
const val ID_OR_SLUG = "idOrSlug"
|
const val ID_OR_SLUG = "idOrSlug"
|
||||||
const val SERIAL = "serial"
|
const val SERIAL = "serial"
|
||||||
const val RUN = "run"
|
const val RUN = "run"
|
||||||
|
const val SERVER_ID = "serverId"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
@@ -140,6 +157,42 @@ object Routes {
|
|||||||
/** One player vendor's shop, by in-game (hex) serial. */
|
/** One player vendor's shop, by in-game (hex) serial. */
|
||||||
fun marketVendor(serial: String) = "shard/market/$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. */
|
/** One creature's atlas page, by slug. */
|
||||||
fun atlasCreature(slug: String) = "atlas/$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,535 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.PollWhileResumed
|
||||||
|
import com.runicgateway.app.ui.Polled
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Rust server: the feed, the leaderboard, who is on, and the wipes (D13).
|
||||||
|
*
|
||||||
|
* **One screen with tabs, not four destinations** — the same call the website
|
||||||
|
* makes, and more obviously right on a phone: the four panels are four questions
|
||||||
|
* about one thing, and a reader moving between them is not navigating.
|
||||||
|
*
|
||||||
|
* The phase criterion lives here. With the server unreachable this still renders
|
||||||
|
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
|
||||||
|
* board and wipe history, because every one of those is read from the website's
|
||||||
|
* own tables rather than from the game.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RustServerScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RustServerViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
PollWhileResumed { viewModel.refresh() }
|
||||||
|
|
||||||
|
when (val s = ui.server.state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
|
||||||
|
// **A mistyped address is not a fault and must not be dressed as one.**
|
||||||
|
// The website's first version put its generic error panel under this
|
||||||
|
// heading, so an unknown id read "No such server / Something went wrong"
|
||||||
|
// and sent a reader looking for an outage. A 404 is its own answer; the
|
||||||
|
// error panel is kept for a request that failed for a reason nobody can
|
||||||
|
// see. A server an operator disabled answers the same 404 — switching one
|
||||||
|
// off is not switching it into a refusal.
|
||||||
|
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
|
||||||
|
MissingServer(onBack, modifier)
|
||||||
|
} else {
|
||||||
|
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
is UiState.Success -> ServerDetail(s.data, ui, viewModel, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rust_no_such_server_detail),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rust_back_to_servers),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerDetail(
|
||||||
|
server: RustServerDto,
|
||||||
|
ui: RustServerUi,
|
||||||
|
viewModel: RustServerViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||||
|
|
||||||
|
val tabs = RustTab.entries
|
||||||
|
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||||
|
tabs.forEach { tab ->
|
||||||
|
Tab(
|
||||||
|
selected = tab == ui.tab,
|
||||||
|
onClick = { viewModel.selectTab(tab) },
|
||||||
|
text = { Text(stringResource(tabLabel(tab))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (ui.tab) {
|
||||||
|
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
|
||||||
|
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||||
|
ui.leaderboard,
|
||||||
|
ui.sort,
|
||||||
|
viewModel::selectSort,
|
||||||
|
viewModel::retryLeaderboard,
|
||||||
|
)
|
||||||
|
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||||
|
RustTab.WIPES -> WipesPanel(
|
||||||
|
ui.wipes,
|
||||||
|
server.wipeId,
|
||||||
|
ui.selectedWipe,
|
||||||
|
viewModel::openWipe,
|
||||||
|
viewModel::retryWipes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tabLabel(tab: RustTab): Int = when (tab) {
|
||||||
|
RustTab.FEED -> R.string.rust_tab_feed
|
||||||
|
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||||
|
RustTab.ONLINE -> R.string.rust_tab_online
|
||||||
|
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerHeader(
|
||||||
|
server: RustServerDto,
|
||||||
|
selectedWipe: String?,
|
||||||
|
onSelectWipe: (String?) -> Unit,
|
||||||
|
wipes: UiState<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)
|
||||||
|
|
||||||
|
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(stringResource(R.string.rust_col_player), Modifier.weight(1f))
|
||||||
|
RUST_COLUMNS.forEach { column ->
|
||||||
|
SectionLabel(
|
||||||
|
text = stringResource(column.labelRes),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.then(
|
||||||
|
if (column.sort != null) {
|
||||||
|
Modifier.clickable { onSort(column.sort) }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.data, key = { it.steamId }) { row ->
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = playerLabel(row.name, row.steamId),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
RUST_COLUMNS.forEach { column ->
|
||||||
|
Text(
|
||||||
|
text = column.value(row),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (column.sort == sort) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Online ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnlinePanel(
|
||||||
|
online: Polled<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(), 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(), 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,185 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
|
import com.runicgateway.app.ui.PollWhileResumed
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Rust server this site follows — the module's landing page (D8, D12).
|
||||||
|
*
|
||||||
|
* **The phase criterion is this screen with every server off.** Nothing here is a
|
||||||
|
* live call to a game host: the website answers from its own tables, so a fleet
|
||||||
|
* that has been down for a week renders a week of last-known state rather than an
|
||||||
|
* error. The one thing that can fail is the website itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RustServersScreen(
|
||||||
|
onOpenServer: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RustServersViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val polled by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
PollWhileResumed { viewModel.refresh() }
|
||||||
|
|
||||||
|
when (val s = polled.state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
|
||||||
|
} else {
|
||||||
|
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerList(
|
||||||
|
servers: List<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)) {
|
||||||
|
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_events">Events</string>
|
||||||
<string name="menu_wiki">Wiki</string>
|
<string name="menu_wiki">Wiki</string>
|
||||||
<string name="menu_shard">Shard</string>
|
<string name="menu_shard">Shard</string>
|
||||||
|
<string name="menu_rust">Rust servers</string>
|
||||||
<string name="menu_rules">Rules</string>
|
<string name="menu_rules">Rules</string>
|
||||||
<string name="menu_atlas">Atlas</string>
|
<string name="menu_atlas">Atlas</string>
|
||||||
<string name="menu_leaderboards">Leaderboards</string>
|
<string name="menu_leaderboards">Leaderboards</string>
|
||||||
@@ -566,4 +567,42 @@
|
|||||||
<string name="events_score">Score %1$s</string>
|
<string name="events_score">Score %1$s</string>
|
||||||
<string name="events_show_more">Show more</string>
|
<string name="events_show_more">Show more</string>
|
||||||
<string name="events_loading">Loading…</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>
|
</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
|
* 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
|
* 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.
|
* 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(
|
private val mergedPublic = listOf(
|
||||||
Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"),
|
Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"),
|
||||||
Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS,
|
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. */
|
/** How many rows that block holds, so the take/drop below say why. */
|
||||||
@@ -154,13 +160,14 @@ class NavOverridesTest {
|
|||||||
// ── Order ────────────────────────────────────────────────────────────
|
// ── Order ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
|
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
|
||||||
// The website's own indices: About is 7, and Market — the last row of all,
|
// The website's own indices: About is 7, Market is 16, and the Rust row —
|
||||||
// now that the module's nine append after core's eight — is 16. Dragging
|
// the last of all, now that a second game module's row appends after the
|
||||||
// About to the top and Home past the end writes exactly this.
|
// first's nine — is 17. Dragging About to the top and Home past the end
|
||||||
|
// writes exactly this.
|
||||||
val routes = routes(
|
val routes = routes(
|
||||||
nav(
|
nav(
|
||||||
"/site/about" to entry(order = 0),
|
"/site/about" to entry(order = 0),
|
||||||
"/" to entry(order = 17),
|
"/" to entry(order = 18),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,7 +175,7 @@ class NavOverridesTest {
|
|||||||
listOf(
|
listOf(
|
||||||
Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD,
|
Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD,
|
||||||
Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET,
|
Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET,
|
||||||
Routes.HOME,
|
Routes.RUST, Routes.HOME,
|
||||||
),
|
),
|
||||||
routes.take(publicBlock),
|
routes.take(publicBlock),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import org.junit.Test
|
|||||||
class NavPathsTest {
|
class NavPathsTest {
|
||||||
|
|
||||||
@Test fun everyWebsiteNavPathIsMapped() {
|
@Test fun everyWebsiteNavPathIsMapped() {
|
||||||
// Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If
|
// Core's eight rows, module-uo's nine and module-rust's one, all quoted in
|
||||||
// either side adds one, this is the test that says so — a path with no
|
// NavPaths.kt. If any of the three adds one, this is the test that says so
|
||||||
// mapping is silently unresolvable in phase 6's link handling, which is
|
// — a path with no mapping is silently unresolvable in phase 6's link
|
||||||
// exactly how the nine shard rows went stale for a month after the
|
// handling, which is exactly how the nine shard rows went stale for a month
|
||||||
// module-system cutover moved them from /site/ to /uo/ (M13).
|
// after the module-system cutover moved them from /site/ to /uo/ (M13).
|
||||||
assertEquals(17, WEBSITE_PUBLIC_NAV.size)
|
assertEquals(18, WEBSITE_PUBLIC_NAV.size)
|
||||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +61,15 @@ class NavPathsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
|
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
|
||||||
// Ten of the seventeen have a drawer row. The other seven are mapped but not
|
// Eleven of the eighteen have a drawer row. The other seven are mapped but
|
||||||
// surfaced — three news category tabs and the four Shard hub boards — and
|
// not surfaced — three news category tabs and the four Shard hub boards —
|
||||||
// an override for one of them is ignored rather than obeyed (§6.2).
|
// 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 coded = APP_MENU.map { it.route }.toSet()
|
||||||
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
|
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ class NavPathsTest {
|
|||||||
listOf(
|
listOf(
|
||||||
"/", "/site/news", "/site/events", "/wiki", "/site/about",
|
"/", "/site/news", "/site/events", "/wiki", "/site/about",
|
||||||
"/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market",
|
"/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market",
|
||||||
|
"/rust",
|
||||||
),
|
),
|
||||||
surfaced,
|
surfaced,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -166,10 +166,12 @@ class NavTreeTest {
|
|||||||
|
|
||||||
val shape = tree(row).shape()
|
val shape = tree(row).shape()
|
||||||
|
|
||||||
// Nine public rows are left at the top level (Wiki moved into the section),
|
// Ten public rows are left at the top level (Wiki moved into the section),
|
||||||
// then the section, then the app's own rows.
|
// then the section, then the app's own rows. The tenth is the Rust row:
|
||||||
assertEquals("section:lore", shape[9])
|
// these tests carry no capability answer, which fails open, so both game
|
||||||
assertEquals(Routes.CONTACT, shape[10])
|
// modules' rows are present — see NavOverridesTest's `mergedPublic`.
|
||||||
|
assertEquals("section:lore", shape[10])
|
||||||
|
assertEquals(Routes.CONTACT, shape[11])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
|
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
|
||||||
@@ -324,9 +326,9 @@ class NavTreeTest {
|
|||||||
val shape = tree(row).shape()
|
val shape = tree(row).shape()
|
||||||
|
|
||||||
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
|
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
|
// The Rust row, not About: both modules' rows append after core's eight on
|
||||||
// website's number line, so Market is the last coded row rather than About.
|
// the website's number line, and Rust's is last at 17.
|
||||||
assertEquals(Routes.SHARD_MARKET, shape[shape.indexOf("link:a") - 1])
|
assertEquals(Routes.RUST, shape[shape.indexOf("link:a") - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
|
@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