5 Commits

Author SHA1 Message Date
37a828736e Merge pull request 'feat(rust): the Rust server list and one server's page — M14 (module-rust phase 5, Android leg A)' (#47) from feature/rust-p5-android-a into edge
Reviewed-on: #47
2026-09-17 09:22:00 +00:00
4b22ab3756 chore(ci): re-run
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m23s
Run 76 hung in `compileDebugKotlin` for thirteen minutes and was failed with no
error in its log, where the last good run finished that task in two. Nothing
about that reads as a compile error, and this repo's Gitea has no rerun
endpoint — so this empty commit is the re-run, to tell a transient runner
problem from a real one before bisecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 04:10:44 -05:00
daf483f514 fix(ci): stop setup-android installing a package Google has removed
Some checks failed
PR Checks / android-build (pull_request) Failing after 18m49s
**Unrelated to this PR's feature work**, and fixed here because it blocks
verifying it (org lead, 2026-09-17). PR #46 passed on this workflow yesterday;
every Android PR fails now.

`android-actions/setup-android@v3` is a floating tag and the action's `packages`
input defaults to `tools` — an obsolete package Google has since removed from the
SDK repository. So the step runs `sdkmanager tools`, gets `Warning: Failed to
find package 'tools'`, exits 1, and CI fails in **Set up Android SDK**, before a
line of this repo is compiled.

`packages: ''` turns that install off. It was always redundant here: the very
next step installs exactly what the build targets — `platform-tools`,
`platforms;android-35`, `build-tools;35.0.0` — precisely so the build never
depends on what some action decided to fetch.

Not addressed here, and worth its own decision: `@v3` is a floating major tag, so
the next upstream change can break CI the same way without warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:50:04 -05:00
a6677d5bf9 fix(rust): what the emulator walk found
Some checks failed
PR Checks / android-build (pull_request) Failing after 2s
Three things, none of which a unit test could have seen.

**The drawer's live count resolved once per process.** It was keyed on the
capability answer alone, so it was read at connect and never again — which is
not what "live" means on a row somebody opens the drawer to look at. It now
refreshes on resume, beside the inbox's unread badge and for the same reason:
coming back to the app is exactly when a stale number would be noticed. Still
never on a timer, still nothing at all on a site without the module.

**Every card's text sat flush against its edge.** `ShardCard` is the themed
`Card` and carries no padding of its own — each caller pads its own content, and
these four did not. On a phone the first glyph of each line read as clipped.

**A name touched its own kill count.** Five numeric columns beside an
equal-weight name column left "Brannock" and "50" reading as one field. The name
now takes a wider share and ellipsizes, and the ACTIVE SORT is marked on the
header rather than by tinting a column of numbers — the header is the control,
and tinting the values says "these are special" instead of "this is what the
table is ordered by".

Walked against the phase-4 rig: a core with the module installed, one live
server and one that has never reported. Both halves of the phase criterion hold
on a phone — the Rust site renders every panel with its server unreachable, and
the same app against the UO core shows its five shard rows and no Rust row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:40:58 -05:00
a6b6c92c33 feat(rust): the Rust server list and one server's page (phase 5, Android leg A)
The app's half of module-rust's read path — the leg R10 says trails the website
surface it consumes by one phase, so it is built against routes that exist.

Two screens, mirroring what phase 4 shipped: `/rust` is the server list (D12),
and one server is a single screen with four tabs (D13) rather than four
destinations. Both render entirely from the website's own tables, so the phase
criterion — a fleet that is entirely off still shows its maps, seeds, wipe
dates, killfeeds, leaderboards and last known presence — holds here for the same
reason it holds on the web.

What is new to the app rather than copied:

- **A poll that is not a load.** `PollWhileResumed` + `refreshInto` (D17): a
  refresh is invisible when it succeeds and KEEPS the rows when it fails. The
  app had one shape for a read — blank, ask, replace — which is right for opening
  a screen and would clear the killfeed three times a minute here. Gated on
  RESUMED, so a backgrounded app makes no requests at all and returning to it
  refreshes at once.
- **A second game module in the drawer.** `Capability.RUST`, gating one row. It
  deliberately does not gate on `servers`/`killfeed`/`leaderboard`/`presence`/
  `wipes`: those name surfaces, core flattens every module's capabilities into
  one list, and another module declaring `servers` would reveal these screens on
  a site with no Rust. Module-Rust#5 adds the identity string.
- **`/rust` in NavPaths**, so an admin's nav override or an added link opens
  natively instead of handing off to a browser (D19).
- **A live player count on the drawer row** (D19) — the phone's answer to D15's
  footer slot, in the same badge slot the inbox count uses, with the same
  screen-reader treatment. Zero renders nothing; a failed read keeps the last
  number; it never polls.

Two things carried across from the website's own page walk rather than
rediscovered: "last reported" reads `lastSeenAt` and never `updatedAt` (a failed
poll moves the second), and a feed row from another calendar day carries its
date, or a row from a past wipe reads as this afternoon.

The four navigation tests that moved did so because APP_MENU gained a row and
the website's nav number line gained an index; each now says which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 22:16:21 -05:00
30 changed files with 3230 additions and 27 deletions

View File

@@ -45,8 +45,15 @@ jobs:
- uses: actions/checkout@v4
# `packages: ''` is load-bearing, not tidying. The action's own default is
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
# default makes `sdkmanager tools` exit 1 and the step fails before a line
# of this repo is compiled. It is redundant here regardless: the next step
# installs exactly what the build targets.
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
# Install exactly what the build targets so it never depends on AGP's
# build-time auto-download. `yes |` accepts any license prompts; `set

View 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
}

View 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,
)

View File

@@ -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 }
}

View File

@@ -159,6 +159,24 @@ object Capability {
*/
const val SHARD = "shard"
/**
* The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5).
*
* A second game module, and therefore a second string rather than a second
* meaning for [SHARD]: a Rust site is a **fleet of servers** with a list
* above them, where a shard is one place — the surfaces are not the same
* shape and a client cannot render one as the other.
*
* `module-rust` also declares `servers`, `killfeed`, `leaderboard`,
* `presence` and `wipes`, and this gates on none of them. Every one of those
* names a SURFACE, and core flattens all modules' capabilities into one list
* — so `servers` is a word another module could declare tomorrow, which would
* silently reveal these rows on a site that does not run Rust. `rust` is the
* string only that module can mean, which is the same job [SHARD] does for
* `module-uo`.
*/
const val RUST = "rust"
/** Core's event system (events Phase 14a). Never a module's. */
const val EVENTS = "events"
}

View File

@@ -21,6 +21,7 @@ import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module
import dagger.Provides
@@ -132,6 +133,19 @@ object NetworkModule {
@Singleton
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
/**
* `module-rust`'s public read path (§9 M14).
*
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
* a backend where an operator installed the Rust module, and the drawer rows
* that lead to them are gated on its `rust` capability. Provided
* unconditionally all the same: a Retrofit interface costs nothing until
* something calls it, and there is nowhere at injection time to ask.
*/
@Provides
@Singleton
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides
@Singleton

View 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)
}
}
}
}

View File

@@ -57,6 +57,7 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
@@ -101,6 +102,9 @@ import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.rust.RustBadgeViewModel
import com.runicgateway.app.ui.rust.RustServerScreen
import com.runicgateway.app.ui.rust.RustServersScreen
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
@@ -118,6 +122,9 @@ private val TOP_LEVEL_ROUTES = setOf(
// gesture works on them. The event page and an arc are detail screens and are
// deliberately absent — a back gesture there means "back", not "open the menu".
Routes.EVENTS, Routes.MY_EVENTS,
// The Rust server list is a drawer row (M14); one server's page is a detail
// screen and is deliberately absent — a back gesture there means "back".
Routes.RUST,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
@@ -140,6 +147,7 @@ fun RunicApp(
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
@@ -154,15 +162,29 @@ fun RunicApp(
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3),
// and re-read the unread count with it: a tickle that arrived while the app was
// away is exactly what brings someone back to it.
LifecycleResumeEffect(Unit) {
// and re-read the two drawer counts with it: a tickle that arrived while the app
// was away is exactly what brings someone back to it, and a live player count is
// only live if it is re-read when somebody looks.
LifecycleResumeEffect(capabilities) {
sessionViewModel.revalidate()
inboxBadgeViewModel.refresh()
// The Rust count is a LIVE number, so it is re-read on the same clock the
// unread badge is: coming back to the app is exactly when a stale one
// would be noticed. Keyed on the capability answer as well as on resume,
// because the very first resume happens before this host has said whether
// the module is there — and asking then would either make a request on a
// site that has no Rust, or never make one at all.
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
onPauseOrDispose { }
}
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
// How many people are on the Rust fleet, for the drawer row's badge — the
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
// timer: a badge is a glance, not a feed. Gated here rather than inside the
// view model because this is the only place that knows whether the module is
// installed at all, and a host that has not answered yet asks nothing.
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
// The badge follows the session, so signing out clears it rather than leaving
// the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
@@ -267,10 +289,17 @@ fun RunicApp(
colors = drawerItemColors,
indented = true,
unread = unread,
rustOnline = rustOnline,
) { openNode(child) }
}
} else {
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
NavRow(
node = node,
currentRoute = currentRoute,
colors = drawerItemColors,
unread = unread,
rustOnline = rustOnline,
) { openNode(node) }
}
}
@@ -386,6 +415,7 @@ private fun NavRow(
colors: NavigationDrawerItemColors,
indented: Boolean = false,
unread: Int = 0,
rustOnline: Int = 0,
onClick: () -> Unit,
) {
val route = when (node) {
@@ -405,6 +435,15 @@ private fun NavRow(
// admin's own nav override pointing at it, since the badge belongs to the
// destination, not to the bundled entry.
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
// The live player count rides on whichever row leads to the Rust list, for the
// same reason the unread count rides on whichever leads to the inbox — the
// number belongs to the destination, not to the bundled entry, so an admin's
// own nav override pointing there carries it too.
//
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
// notification, and a badge that read `0` on a site whose servers are simply
// quiet would be worse than no badge at all.
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
NavigationDrawerItem(
label = { Text(label) },
@@ -432,6 +471,19 @@ private fun NavRow(
)
}
}
showsRustOnline -> {
{
// Named for a screen reader: "42" beside "Rust servers" reads
// as a count to a sighted user and as a bare number to
// everyone else.
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
Text(
text = rustOnline.toString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.semantics { contentDescription = spoken },
)
}
}
else -> null
},
colors = colors,
@@ -573,6 +625,17 @@ private fun RunicNavHost(
) { entry ->
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
}
// The Rust module's two screens (M14). Not under `shard/`: a different game,
// a different shape — a fleet with a list above it rather than one place.
composable(Routes.RUST) {
RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) })
}
composable(
route = Routes.RUST_SERVER,
arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }),
) {
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}

View File

@@ -128,6 +128,17 @@ val APP_MENU: List<MenuEntry> = listOf(
feature = ShardFeature.MARKET,
capability = Capability.SHARD,
),
// The Rust module (M14). ONE row, because the module's whole public surface is
// one list and one page beneath it — `/rust` IS the server list, not a hub
// above one.
//
// **No `feature`, and that is not an omission.** The visibility framework is
// `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/`
// and none under core's), and §2.7 forbids a module importing another's — so
// `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds
// one; until then these routes are public to everyone the site is public to,
// and a `feature` here would be gating on a flag nothing publishes.
MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),

View File

@@ -70,6 +70,15 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
* })
* ```
*
* and from `module-rust/client/src/entry.jsx`, which registers one (M14):
*
* ```jsx
* registry.registerNav(ID, {
* area: 'public',
* items: [{ label: 'Servers', to: '/rust' }],
* })
* ```
*
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
* rows in registration order — which is the order they are listed in below.
*
@@ -119,6 +128,16 @@ val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/uo/atlas", Routes.ATLAS),
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/uo/market", Routes.SHARD_MARKET),
// module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }`
// and nothing else — `/rust` IS the server list, because core strips the
// trailing separator from a module route registered with `path: ''`.
//
// **Both modules can be installed on one backend**, and then the nav is core's
// eight plus ten. This table is a superset by design: a path here for a module
// an operator has NOT installed never appears in that backend's nav and so is
// never looked up, while a path missing from it makes a link that exists hand
// off to a browser.
WebNavPath("/rust", Routes.RUST),
)
/**
@@ -178,7 +197,12 @@ private val RESERVED_TOP_LEVEL = setOf(
// Only ids the app knows about need listing: an unknown module's `/<id>` would
// resolve to a CMS page that 404s, which is the same answer the browser gives
// it, and core cannot enumerate them for us here anyway.
"uo",
//
// `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to
// the server list, and it does so through the nav table above — this set only
// stops the CMS-page fallback claiming it. Without the entry a site with the
// module absent would open a page-not-found screen instead of the browser.
"uo", "rust",
)
/**
@@ -209,6 +233,7 @@ private val RESERVED_TOP_LEVEL = setOf(
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
* // /rust, /rust/servers/:id
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
@@ -231,6 +256,8 @@ private val RESERVED_TOP_LEVEL = setOf(
* /uo/<shard surface> → the mapped shard route (§6.2)
* /uo/atlas/<slug> → ATLAS_CREATURE
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /rust → RUST (module-rust's server list)
* /rust/servers/<id> → RUST_SERVER
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
@@ -295,6 +322,10 @@ fun resolveWebPath(path: String?): String? {
Routes.atlasCreature(segments[2])
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
// module-rust's one page below the list. `/rust` itself is already
// answered by the nav table above, before this fallback is reached.
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
Routes.rustServer(segments[2])
else -> null
}
}
@@ -322,3 +353,6 @@ private fun runParam(query: String): String? {
* module is countable. It is a literal on purpose — see the file header.
*/
private const val MODULE_UO = "uo"
/** The second game module's id (M14). A literal for the same reason. */
private const val MODULE_RUST = "rust"

View File

@@ -70,6 +70,22 @@ object Routes {
const val EVENT_SERIES = "events/series/{slug}"
const val MY_EVENTS = "account/events"
/**
* The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13).
*
* **[RUST] is the server list, not a hub above one.** The module registers its
* pages with `path: ''` and core strips the trailing separator, so `/rust` on
* the website *is* the list — there is no landing page between the drawer row
* and the servers, and adding one here would invent a screen the website does
* not have.
*
* **A different game, so a different route tree.** These are deliberately not
* folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The
* two can be installed on the same backend, and then both trees exist at once.
*/
const val RUST = "rust"
const val RUST_SERVER = "rust/servers/{serverId}"
/** Public shard hub (§6.2). */
const val SHARD = "shard"
@@ -121,6 +137,7 @@ object Routes {
const val ID_OR_SLUG = "idOrSlug"
const val SERIAL = "serial"
const val RUN = "run"
const val SERVER_ID = "serverId"
}
fun page(slug: String) = "page/$slug"
@@ -140,6 +157,42 @@ object Routes {
/** One player vendor's shop, by in-game (hex) serial. */
fun marketVendor(serial: String) = "shard/market/$serial"
/**
* One Rust server's page.
*
* The id is a slug an operator chose, so it is encoded: nothing stops one
* carrying a character a path would otherwise eat, and a server nobody can
* open is a worse failure than a name nobody can read.
*
* **Encoded here rather than with `android.net.Uri`**, which is a stub in a
* JVM unit test and throws "not mocked" — this object is pure and every test
* that builds a route would have to become an instrumented one to keep it
* that way.
*/
fun rustServer(id: String) = "rust/servers/${encodePathSegment(id)}"
/**
* Percent-encode one path segment, allowing only the unreserved set.
*
* Deliberately stricter than it needs to be: encoding a character that did
* not need it still round-trips, where missing one that did produces a route
* NavHost matches differently from the one that was built. UTF-8 first, so a
* non-ASCII name is encoded per byte rather than per character.
*/
private fun encodePathSegment(value: String): String = buildString {
for (byte in value.toByteArray(Charsets.UTF_8)) {
val code = byte.toInt() and 0xFF
val char = code.toChar()
if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) {
append(char)
} else {
append('%').append(code.toString(16).uppercase().padStart(2, '0'))
}
}
}
private const val UNRESERVED = "-._~"
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"

View File

@@ -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
}
}
}
}

View 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)
}
}

View 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)

View File

@@ -0,0 +1,566 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.PollWhileResumed
import com.runicgateway.app.ui.Polled
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* One Rust server: the feed, the leaderboard, who is on, and the wipes (D13).
*
* **One screen with tabs, not four destinations** — the same call the website
* makes, and more obviously right on a phone: the four panels are four questions
* about one thing, and a reader moving between them is not navigating.
*
* The phase criterion lives here. With the server unreachable this still renders
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
* board and wipe history, because every one of those is read from the website's
* own tables rather than from the game.
*/
@Composable
fun RustServerScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RustServerViewModel = hiltViewModel(),
) {
val ui by viewModel.state.collectAsStateWithLifecycle()
PollWhileResumed { viewModel.refresh() }
when (val s = ui.server.state) {
is UiState.Loading -> LoadingView(modifier)
// **A mistyped address is not a fault and must not be dressed as one.**
// The website's first version put its generic error panel under this
// heading, so an unknown id read "No such server / Something went wrong"
// and sent a reader looking for an outage. A 404 is its own answer; the
// error panel is kept for a request that failed for a reason nobody can
// see. A server an operator disabled answers the same 404 — switching one
// off is not switching it into a refusal.
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
MissingServer(onBack, modifier)
} else {
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
}
is UiState.Success -> ServerDetail(s.data, ui, viewModel, modifier)
}
}
@Composable
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
Text(
text = stringResource(R.string.rust_no_such_server_detail),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.rust_back_to_servers),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
)
}
}
@Composable
private fun ServerDetail(
server: RustServerDto,
ui: RustServerUi,
viewModel: RustServerViewModel,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxSize()) {
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
val tabs = RustTab.entries
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
tabs.forEach { tab ->
Tab(
selected = tab == ui.tab,
onClick = { viewModel.selectTab(tab) },
text = { Text(stringResource(tabLabel(tab))) },
)
}
}
when (ui.tab) {
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
RustTab.LEADERBOARD -> LeaderboardPanel(
ui.leaderboard,
ui.sort,
viewModel::selectSort,
viewModel::retryLeaderboard,
)
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
RustTab.WIPES -> WipesPanel(
ui.wipes,
server.wipeId,
ui.selectedWipe,
viewModel::openWipe,
viewModel::retryWipes,
)
}
}
}
private fun tabLabel(tab: RustTab): Int = when (tab) {
RustTab.FEED -> R.string.rust_tab_feed
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
RustTab.ONLINE -> R.string.rust_tab_online
RustTab.WIPES -> R.string.rust_tab_wipes
}
@Composable
private fun ServerHeader(
server: RustServerDto,
selectedWipe: String?,
onSelectWipe: (String?) -> Unit,
wipes: UiState<List<RustWipeDto>>,
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall)
describeWorld(server)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (server.online) {
StatusPill(
text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers),
tone = PillTone.Success,
)
} else {
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
}
Text(
text = lastReported(server),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// The wipe picker sits above the tabs because it filters two of them. It
// is absent until the wipe list has loaded — offering a filter with one
// option would look like a server that has only ever had one wipe.
val available = (wipes as? UiState.Success)?.data.orEmpty()
if (available.isNotEmpty()) {
WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe)
}
}
}
@Composable
private fun WipeFilter(
wipes: List<RustWipeDto>,
currentWipeId: String?,
selected: String?,
onSelect: (String?) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// **Null is all time, and it is the default.** It is a real choice rather
// than an absent filter: all-time is the per-wipe rows summed, which is
// the answer to "who plays here", where a wipe is the answer to "who is
// winning now".
FilterChip(
selected = selected == null,
onClick = { onSelect(null) },
label = { Text(stringResource(R.string.rust_all_time)) },
)
wipes.forEach { wipe ->
val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId
FilterChip(
selected = selected == wipe.wipeId,
onClick = { onSelect(wipe.wipeId) },
label = {
Text(
if (wipe.wipeId == currentWipeId) {
stringResource(R.string.rust_wipe_current, label)
} else {
label
},
)
},
)
}
}
}
// ── Feed ──────────────────────────────────────────────────────────────────
@Composable
private fun FeedPanel(
feed: Polled<List<RustEventDto>>,
filterId: String,
onFilter: (String) -> Unit,
onRetry: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FEED_FILTERS.forEach { filter ->
FilterChip(
selected = filter.id == filterId,
onClick = { onFilter(filter.id) },
label = { Text(filter.label) },
)
}
}
when (val s = feed.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.rust_feed_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (feed.refreshFailed) {
item { RefreshFailedLine() }
}
items(s.data, key = { it.id }) { FeedRow(it) }
}
}
}
}
}
@Composable
private fun FeedRow(row: RustEventDto) {
val line = describe(row)
Row(verticalAlignment = Alignment.Top) {
// The stamp carries a date for anything not from today — a row from six
// weeks ago rendered as a bare time reads as this afternoon, which is
// exactly what happens the moment the feed is filtered to a past wipe.
Text(
text = feedClock(value = null, epochMillis = row.t),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 10.dp, top = 2.dp),
)
Column {
Row {
line.actor?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(line.join, style = MaterialTheme.typography.bodyMedium)
}
// The chat message lands here, and it is the one field on this
// wire whose bytes a player chooses. Compose renders it as text
// and never as markup; nothing here may ever stop doing that.
Text(line.verb, style = MaterialTheme.typography.bodyMedium)
line.subject?.let {
Text(" ", style = MaterialTheme.typography.bodyMedium)
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
}
if (line.detail.isNotBlank()) {
Text(
text = line.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// ── Leaderboard ───────────────────────────────────────────────────────────
/**
* The columns, and which of them the API can sort by.
*
* `structures` has no sort on the wire and therefore no tap here — a header that
* sorts by something other than what it says is worse than one that does not
* sort.
*/
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
/** The name's share of the row against one numeric column's. */
private const val NAME_WEIGHT = 1.7f
/** How far a header that is not the current sort is faded. */
private const val SORTED_AWAY = 0.55f
private val RUST_COLUMNS = listOf(
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() },
RustColumn(R.string.rust_col_structures, null) { it.structures.toString() },
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
)
@Composable
private fun LeaderboardPanel(
state: UiState<List<RustLeaderboardRowDto>>,
sort: String,
onSort: (String) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> if (state.data.isEmpty()) {
// **Empty is a real answer here and is not "no data".** All-time is
// the per-wipe rows summed, so a player who appears only in an older
// wipe drops out of the current one rather than reading zero — an
// empty board for a wipe means nobody scored on that map.
EmptyView(stringResource(R.string.rust_leaderboard_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Row(Modifier.fillMaxWidth()) {
SectionLabel(
text = stringResource(R.string.rust_col_player),
modifier = Modifier.weight(NAME_WEIGHT),
)
RUST_COLUMNS.forEach { column ->
// The ACTIVE sort is marked on the header, not on the
// values: the header is the control, and tinting a
// column of numbers instead says "these are special"
// rather than "this is what the table is ordered by".
SectionLabel(
text = stringResource(column.labelRes),
modifier = Modifier
.weight(1f)
.then(
if (column.sort != null) {
Modifier.clickable { onSort(column.sort) }
} else {
Modifier
},
)
.then(
if (column.sort == sort) {
Modifier.alpha(1f)
} else {
Modifier.alpha(SORTED_AWAY)
},
),
)
}
}
}
items(state.data, key = { it.steamId }) { row ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
// A wider share for the name, and one line with an ellipsis.
// Five numeric columns beside an equal-weight name column
// left "Brannock" touching its own kill count, which the
// walk read as one field.
Text(
text = playerLabel(row.name, row.steamId),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
)
RUST_COLUMNS.forEach { column ->
Text(
text = column.value(row),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
// ── Online ────────────────────────────────────────────────────────────────
@Composable
private fun OnlinePanel(
online: Polled<List<RustPresenceDto>>,
serverOnline: Boolean,
onRetry: () -> Unit,
) {
when (val s = online.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.isEmpty()) {
EmptyView(
stringResource(
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
),
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// **The board is the last one that ARRIVED, and an unreachable
// server does not clear it** — deliberately, because these rows
// are still the best answer anybody has. Presented bare they read
// as "these people are on right now", which is the one thing an
// offline server cannot be saying. So the panel says which it is.
item {
Text(
text = stringResource(
if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (online.refreshFailed) {
item { RefreshFailedLine() }
}
items(s.data, key = { it.steamId }) { player ->
ShardCard(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = playerLabel(player.name, player.steamId),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (player.sleeping) {
StatusPill(
text = stringResource(R.string.rust_sleeping),
tone = PillTone.Neutral,
)
}
}
}
}
}
}
}
}
// ── Wipes ─────────────────────────────────────────────────────────────────
@Composable
private fun WipesPanel(
state: UiState<List<RustWipeDto>>,
currentWipeId: String?,
selected: String?,
onOpenWipe: (String) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> if (state.data.isEmpty()) {
EmptyView(stringResource(R.string.rust_wipes_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.data, key = { it.wipeId }) { wipe ->
ShardCard(
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (wipe.wipeId == currentWipeId) {
StatusPill(
text = stringResource(R.string.rust_wipe_this_one),
tone = PillTone.Success,
)
} else if (wipe.wipeId == selected) {
StatusPill(
text = stringResource(R.string.rust_wipe_selected),
tone = PillTone.Info,
)
}
}
}
}
}
}
}
}
@Composable
private fun RefreshFailedLine() {
Text(
text = stringResource(R.string.rust_refresh_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,191 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.ui.PollWhileResumed
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* Every Rust server this site follows — the module's landing page (D8, D12).
*
* **The phase criterion is this screen with every server off.** Nothing here is a
* live call to a game host: the website answers from its own tables, so a fleet
* that has been down for a week renders a week of last-known state rather than an
* error. The one thing that can fail is the website itself.
*/
@Composable
fun RustServersScreen(
onOpenServer: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: RustServersViewModel = hiltViewModel(),
) {
val polled by viewModel.state.collectAsStateWithLifecycle()
PollWhileResumed { viewModel.refresh() }
when (val s = polled.state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
} else {
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
}
}
}
}
@Composable
private fun ServerList(
servers: List<RustServerDto>,
refreshFailed: Boolean,
onOpenServer: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// A failed refresh says so and changes nothing else. The rows below it are
// the last good answer and stay exactly as they were — blanking them is
// the one thing a site whose premise is "it renders while the game is off"
// must not do when a request fails.
if (refreshFailed) {
item {
Text(
text = stringResource(R.string.rust_refresh_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(servers, key = { it.id }) { server ->
ServerRow(server) { onOpenServer(server.id) }
}
}
}
@Composable
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
// `ShardCard` is the themed Card and nothing more — it carries no padding
// of its own, so every caller pads its own content. Without this the text
// sits flush against the card's edge and the first glyph of each line
// reads as clipped, which is what the walk saw.
Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = server.name.ifBlank { server.id },
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// `online` already has staleness folded into it server-side — a row
// nobody has written recently cannot claim a server is up — so this
// renders the field rather than second-guessing it.
if (server.online) {
StatusPill(
text = stringResource(
R.string.rust_online_count,
server.players,
server.maxPlayers,
),
tone = PillTone.Success,
)
} else {
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
}
}
val world = describeWorld(server)
if (world != null) {
Text(
text = world,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Text(
text = lastReported(server),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/**
* The world line — the things a Rust player asks first.
*
* Null when the server has never described itself, so the caller leaves the line
* out rather than printing an empty one. A server configured this morning that
* has not connected yet is in exactly that state, and it is not an error.
*/
@Composable
internal fun describeWorld(server: RustServerDto): String? {
val parts = listOfNotNull(
server.level,
server.worldSize?.let { stringResource(R.string.rust_world_size, it) },
server.seed?.let { stringResource(R.string.rust_world_seed, it) },
wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) },
)
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* "last reported 3 minutes ago".
*
* **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact
* confusion and fixed it in phase 4: `updatedAt` moves on every poll including a
* failed one, so an offline server claimed it had just checked in, every thirty
* seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`.
*/
@Composable
internal fun lastReported(server: RustServerDto): String {
val ago = rustAgo(server.lastSeenAt)
?: return stringResource(R.string.rust_never_reported)
return if (server.stale) {
stringResource(R.string.rust_last_reported_stale, ago)
} else {
stringResource(R.string.rust_last_reported, ago)
}
}

View File

@@ -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())
}
}

View File

@@ -44,6 +44,7 @@
<string name="menu_events">Events</string>
<string name="menu_wiki">Wiki</string>
<string name="menu_shard">Shard</string>
<string name="menu_rust">Rust servers</string>
<string name="menu_rules">Rules</string>
<string name="menu_atlas">Atlas</string>
<string name="menu_leaderboards">Leaderboards</string>
@@ -566,4 +567,42 @@
<string name="events_score">Score %1$s</string>
<string name="events_show_more">Show more</string>
<string name="events_loading">Loading…</string>
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
<string name="rust_offline">Offline</string>
<string name="rust_online_count">%1$d / %2$d online</string>
<string name="rust_never_reported">has never reported</string>
<string name="rust_last_reported">last reported %1$s</string>
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
<string name="rust_world_size">size %1$d</string>
<string name="rust_world_seed">seed %1$d</string>
<string name="rust_wiped_on">wiped %1$s</string>
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
<string name="rust_no_such_server">No such server</string>
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
<string name="rust_back_to_servers">Back to the server list</string>
<string name="rust_tab_feed">Feed</string>
<string name="rust_tab_leaderboard">Leaderboard</string>
<string name="rust_tab_online">Online</string>
<string name="rust_tab_wipes">Wipes</string>
<string name="rust_all_time">All time</string>
<string name="rust_wipe_current">%1$s (this wipe)</string>
<string name="rust_wipe_this_one">Current</string>
<string name="rust_wipe_selected">Showing</string>
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
<string name="rust_presence_live">On the server right now.</string>
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
<string name="rust_sleeping">Sleeping</string>
<string name="rust_col_player">Player</string>
<string name="rust_col_kills">Kills</string>
<string name="rust_col_deaths">Deaths</string>
<string name="rust_col_npc_kills">NPC</string>
<string name="rust_col_structures">Built</string>
<string name="rust_col_played">Played</string>
<string name="rust_online_badge">%1$d players online</string>
</resources>

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -52,11 +52,17 @@ class NavOverridesTest {
* showing through** (M13): About is core's last nav row at index 7 and the
* module's nine append after it at 8-16. Under the stale sixteen-row table
* About was index 15 and came last, which is what these assertions used to say.
*
* **The Rust row is last, at 17** (M14). These tests carry no capability
* answer, which fails open, so both game modules' rows appear here — a state
* no real backend is in and exactly the one this merge has to be correct for,
* since the sort key is the website's number line and not what happens to be
* installed.
*/
private val mergedPublic = listOf(
Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"),
Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS,
Routes.SHARD_MARKET,
Routes.SHARD_MARKET, Routes.RUST,
)
/** How many rows that block holds, so the take/drop below say why. */
@@ -154,13 +160,14 @@ class NavOverridesTest {
// ── Order ────────────────────────────────────────────────────────────
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
// The website's own indices: About is 7, and Market the last row of all,
// now that the module's nine append after core's eight — is 16. Dragging
// About to the top and Home past the end writes exactly this.
// The website's own indices: About is 7, Market is 16, and the Rust row
// the last of all, now that a second game module's row appends after the
// first's nine — is 17. Dragging About to the top and Home past the end
// writes exactly this.
val routes = routes(
nav(
"/site/about" to entry(order = 0),
"/" to entry(order = 17),
"/" to entry(order = 18),
),
)
@@ -168,7 +175,7 @@ class NavOverridesTest {
listOf(
Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD,
Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET,
Routes.HOME,
Routes.RUST, Routes.HOME,
),
routes.take(publicBlock),
)

View File

@@ -19,12 +19,12 @@ import org.junit.Test
class NavPathsTest {
@Test fun everyWebsiteNavPathIsMapped() {
// Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If
// either side adds one, this is the test that says so — a path with no
// mapping is silently unresolvable in phase 6's link handling, which is
// exactly how the nine shard rows went stale for a month after the
// module-system cutover moved them from /site/ to /uo/ (M13).
assertEquals(17, WEBSITE_PUBLIC_NAV.size)
// Core's eight rows, module-uo's nine and module-rust's one, all quoted in
// NavPaths.kt. If any of the three adds one, this is the test that says so
// — a path with no mapping is silently unresolvable in phase 6's link
// handling, which is exactly how the nine shard rows went stale for a month
// after the module-system cutover moved them from /site/ to /uo/ (M13).
assertEquals(18, WEBSITE_PUBLIC_NAV.size)
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
}
@@ -61,9 +61,15 @@ class NavPathsTest {
}
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
// Ten of the seventeen have a drawer row. The other seven are mapped but not
// surfaced — three news category tabs and the four Shard hub boards — and
// an override for one of them is ignored rather than obeyed (§6.2).
// Eleven of the eighteen have a drawer row. The other seven are mapped but
// not surfaced — three news category tabs and the four Shard hub boards —
// and an override for one of them is ignored rather than obeyed (§6.2).
//
// **Both game modules appear here, and no backend serves both lists.** The
// table is a superset on purpose: a path for a module an operator has not
// installed never appears in that backend's nav and is never looked up,
// while a path MISSING from it makes a link that does exist hand off to a
// browser.
val coded = APP_MENU.map { it.route }.toSet()
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
@@ -71,6 +77,7 @@ class NavPathsTest {
listOf(
"/", "/site/news", "/site/events", "/wiki", "/site/about",
"/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market",
"/rust",
),
surfaced,
)

View File

@@ -166,10 +166,12 @@ class NavTreeTest {
val shape = tree(row).shape()
// Nine public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows.
assertEquals("section:lore", shape[9])
assertEquals(Routes.CONTACT, shape[10])
// Ten public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows. The tenth is the Rust row:
// these tests carry no capability answer, which fails open, so both game
// modules' rows are present — see NavOverridesTest's `mergedPublic`.
assertEquals("section:lore", shape[10])
assertEquals(Routes.CONTACT, shape[11])
}
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
@@ -324,9 +326,9 @@ class NavTreeTest {
val shape = tree(row).shape()
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
// Market, not About: the module's nine rows append after core's eight on the
// website's number line, so Market is the last coded row rather than About.
assertEquals(Routes.SHARD_MARKET, shape[shape.indexOf("link:a") - 1])
// The Rust row, not About: both modules' rows append after core's eight on
// the website's number line, and Rust's is last at 17.
assertEquals(Routes.RUST, shape[shape.indexOf("link:a") - 1])
}
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {

View File

@@ -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"))
}
}

View File

@@ -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)
}
}

View 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)
}
}

View 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"))
}
}

View 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))
}
}

View File

@@ -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)
}
}