diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 57dc269..1df823f 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0df18df..4594d20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,13 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) } +// StringVocabularyTest (PLAN.md M20) reads the string resources from disk. A reworded +// string changes no class, so without this input the test task would stay UP-TO-DATE +// and the guard would not run again. +tasks.withType().configureEach { + inputs.dir("src/main/res").withPathSensitivity(PathSensitivity.RELATIVE) +} + // JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated, // DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number; // pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushContent.kt b/app/src/main/java/com/runicgateway/app/core/push/PushContent.kt new file mode 100644 index 0000000..c03ccc4 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/push/PushContent.kt @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.push + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.api.dto.NotificationItemDto +import com.runicgateway.app.data.repository.NotificationsRepository +import com.runicgateway.app.ui.navigation.Routes +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The inbox id a tickle's `notification:` ref names, or null. + * + * Prefix-exact, the same rule [Routes.forTickle] reads the ref by, and digits + * only. The relay is untrusted, so a ref is a hint, and anything that is not + * exactly a positive id is treated as no hint at all. + */ +internal fun inboxIdFromRef(ref: String?): Long? { + if (ref == null || !ref.startsWith(Routes.INBOX_REF_PREFIX)) return null + val digits = ref.substring(Routes.INBOX_REF_PREFIX.length) + if (digits.isEmpty() || digits.length > MAX_ID_DIGITS || !digits.all { it in '0'..'9' }) return null + return digits.toLongOrNull()?.takeIf { it > 0 } +} + +/** + * The item from [page] that [tickle] is about, or null. + * + * The item must be the one the ref names **and** come from the trigger the tickle + * names. Only the user's own inbox can be read, so a forged pair could only put + * one of their own items on their own phone. A tickle that disagrees with the row + * it points at is still not one to title from. + */ +internal fun itemForTickle(tickle: PushTickle, page: List): NotificationItemDto? { + val id = inboxIdFromRef(tickle.ref) ?: return null + return page.firstOrNull { it.id == id && it.triggerId == tickle.stream } +} + +/** + * Pulls the inbox row a tickle points at, so the notification can say what + * happened (`docs/modules/rust/PLAN.md` D70). + * + * This is the wake-and-pull contract core's `pushChannel.js` describes: the relay + * carries `{ stream, ref }` and nothing else, and the content comes over the + * authenticated, ownership-checked inbox API. Core has no single-item read, so + * this reads the first page. The row was written moments ago, so it is on that + * page, and if it is not the caller falls back to the per-stream title. + * + * **Every failure is null**: no ref, a signed-out app, a dead network, a timeout, + * an item not on the page. A GET of the inbox marks nothing read, so the badge + * is left alone. + */ +@Singleton +class PushContentResolver @Inject constructor( + private val notifications: NotificationsRepository, +) { + suspend fun itemFor(tickle: PushTickle): NotificationItemDto? { + if (inboxIdFromRef(tickle.ref) == null) return null + val page = withTimeoutOrNull(PULL_TIMEOUT_MS) { + (notifications.inbox() as? ApiResult.Ok)?.data?.items + } ?: return null + return itemForTickle(tickle, page) + } + + private companion object { + /** + * Long enough for a phone waking on a slow network, and short enough that a + * notification is never held back noticeably for a title. + */ + const val PULL_TIMEOUT_MS = 5_000L + } +} + +/** A `BIGINT UNSIGNED` never runs past 20 digits. */ +private const val MAX_ID_DIGITS = 20 diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt b/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt index 6e1acf3..c5760ff 100644 --- a/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt +++ b/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt @@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.runicgateway.app.MainActivity import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.NotificationItemDto import dagger.hilt.android.qualifiers.ApplicationContext import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @@ -20,10 +21,18 @@ import javax.inject.Singleton /** * Builds the notification channels and posts a notification for a received tickle - * (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream** - * notification titled from the fixed [PushStreams] catalog — the content-free tickle - * carries nothing to render, so nothing is fetched to display the notification; tapping - * deep-links into [MainActivity] (which fetches fresh over the authenticated API). + * (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity], + * which fetches fresh over the authenticated API. + * + * **The title comes from the inbox row when there is one** (`docs/modules/rust/PLAN.md` + * D70). The tickle carries nothing to render. When its ref names an inbox row, + * [PushContentResolver] has already pulled that row over the authenticated API, and + * the notification says what the row says. Every other tickle keeps the M7 behaviour: + * a **generic per-stream** title from the fixed [PushStreams] catalog. Until D70 that + * was every tickle, and "New notification" is all a Rust raid alert ever said. + * + * **The lock screen shows only the generic title.** The row's text is content, and + * a locked phone on a table is not the place for "a door was destroyed in S16". */ @Singleton class PushNotifier @Inject constructor( @@ -65,17 +74,32 @@ class PushNotifier @Inject constructor( .setContentIntent(deepLinkIntent(stream = null, ref = null)) .build() - /** Post a notification for a tickle, deep-linking to the stream's screen on tap. */ - fun notify(tickle: PushTickle) { + /** + * Post a notification for a tickle, deep-linking to the stream's screen on tap. + * + * [item] is the inbox row the tickle points at, when it could be pulled; null + * falls back to the per-stream title. + */ + fun notify(tickle: PushTickle, item: NotificationItemDto? = null) { if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted - val title = context.getString(PushStreams.titleRes(tickle.stream)) - val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES) - .setContentTitle(title) + val generic = context.getString(PushStreams.titleRes(tickle.stream)) + val content = notificationText(item, generic) + val redacted = NotificationCompat.Builder(context, CHANNEL_MESSAGES) + .setContentTitle(generic) + .setSmallIcon(R.drawable.ic_stat_name) + .build() + val builder = NotificationCompat.Builder(context, CHANNEL_MESSAGES) + .setContentTitle(content.title) .setSmallIcon(R.drawable.ic_stat_name) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(redacted) .setContentIntent(deepLinkIntent(tickle.stream, tickle.ref)) - .build() + content.body?.let { body -> + builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body)) + } + val notification = builder.build() try { manager.notify(nextId.getAndIncrement(), notification) } catch (_: SecurityException) { @@ -108,3 +132,18 @@ class PushNotifier @Inject constructor( const val EXTRA_REF = "com.runicgateway.app.push.REF" } } + +/** What a posted notification says: a title always, and a body when the row had one. */ +internal data class NotificationText(val title: String, val body: String?) + +/** + * The text for a notification about [item], falling back to [generic] (D70). + * + * A row with a blank title is treated as no row: a notification with an empty + * headline is worse than one that says only that something happened. + */ +internal fun notificationText(item: NotificationItemDto?, generic: String): NotificationText { + val title = item?.title?.trim().orEmpty() + if (title.isEmpty()) return NotificationText(generic, null) + return NotificationText(title, item?.body?.trim()?.takeIf { it.isNotEmpty() }) +} diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt index 71e7607..dc8aa7e 100644 --- a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt +++ b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,6 +32,7 @@ class PushService : Service() { @Inject lateinit var streamClient: NtfyStreamClient @Inject lateinit var notifier: PushNotifier + @Inject lateinit var content: PushContentResolver @Inject lateinit var prefs: PushPreferences private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -67,8 +67,15 @@ class PushService : Service() { stopSelf() return } - streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event -> - if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle) + // Each tickle in its own job (D70). Titling one from its inbox row + // suspends on a network read, and inside `collectLatest` the next tickle + // would cancel it. A burst (two owners' raid alerts, then a restart) would + // then post only the last. + streamClient.events(snapshot.ntfyUrl, topic).collect { event -> + if (event is NtfyStreamClient.Event.Message) { + val tickle = event.tickle + scope.launch { notifier.notify(tickle, content.itemFor(tickle)) } + } } } diff --git a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt index 49df279..1114bae 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt @@ -27,6 +27,7 @@ import retrofit2.http.HTTP import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path +import retrofit2.http.Query /** * The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential @@ -60,8 +61,10 @@ interface MeApi { // DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the // path template explicit alongside the provider argument. + // `id` (optional) removes that one identity rather than every one of the + // provider — a person may link several Steam accounts (STEAM_SIGNIN.md). @HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}") - suspend fun unlinkIdentity(@Path("provider") provider: String): Unit + suspend fun unlinkIdentity(@Path("provider") provider: String, @Query("id") id: Int? = null): Unit // ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ── diff --git a/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt new file mode 100644 index 0000000..2faa596 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api + +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustEconomicsDto +import com.runicgateway.app.data.api.dto.RustLinkListDto +import com.runicgateway.app.data.api.dto.RustLinkRequest +import com.runicgateway.app.data.api.dto.RustLinkResultDto +import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.dto.RustUnlinkResultDto +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * A player's own Rust identity and what it earns them + * (`docs/modules/rust/PLAN.md` §19, §20, §22; M15), over the bearer-gated + * `/player/rust/…` surface. + * + * Its own interface beside [RustApi] rather than four more methods on it, for + * the same reason [PlayerShardApi] is separate from [PublicApi]: these need a + * session and those do not, and one interface holding both makes the tier a + * property of the method name instead of the type. + * + * **The paths are hardcoded, which is the contract and not a shortcut.** + * `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so + * the app cannot build `//links` from what `GET /public/modules` + * reports. A capability answers *is the module there*; these four addresses are + * knowledge the app has because someone read the module's router. + */ +interface PlayerRustApi { + + /** The Steam accounts the caller holds. Fleet-wide: a link is not per server. */ + @GET("api/v1/player/rust/links") + suspend fun links(): RustLinkListDto + + /** + * Redeem the code `/link` handed the player in game. + * + * **The refusals are not interchangeable and the screen must not flatten + * them.** A 400 is a code that is unknown or expired — go and get another; + * a 409 is a Steam account another website account holds — run `/unlink` in + * game; a 503 is a server that could not be reached, where the code is still + * good and the only right advice is to wait a minute. A player told to run + * `/link` again when the server their code came from was merely down will + * get another code from the same down server. The server sends a sentence + * for each; this leg renders it rather than writing one of its own. + * + * Rate-limited server-side (ten per quarter-hour per IP), so a 429 is an + * ordinary answer here rather than a bug. + */ + @POST("api/v1/player/rust/link") + suspend fun link(@Body body: RustLinkRequest): RustLinkResultDto + + /** + * Release a link the caller holds. + * + * Scoped to the caller inside the server's statement, so a Steam id that is + * somebody else's answers the same 404 as one that is nobody's. + */ + @DELETE("api/v1/player/rust/links/{steamId}") + suspend fun unlink(@Path("steamId") steamId: String): RustUnlinkResultDto + + /** + * What the site has given this player in game — ranks and direct grants, + * each already resolved to the servers its scope reaches. + * + * Read-only by construction: everything that authors one of these rows is an + * admin route. + */ + @GET("api/v1/player/rust/permissions") + suspend fun permissions(): RustPlayerPermissionsDto + + /** + * The caller's coin balances on every server, per linked account + * (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2). Live where a server + * answers, the last balance it reported where it does not. A module older + * than protocol 14 answers 404, and the screen then shows no coins at all. + */ + @GET("api/v1/player/rust/economics") + suspend fun economics(): RustEconomicsDto + + /** + * The servers this site follows, on the authenticated tier — the choices the + * backpack section offers. The same list as the public one. + */ + @GET("api/v1/player/rust/servers") + suspend fun servers(): RustServerListDto + + /** + * One of the caller's own backpacks on one server + * (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2), read live from the game. + * 409 is a server without Backpacks; a module older than protocol 14 answers + * 404, and the screen then says the site cannot show backpacks yet. + */ + @GET("api/v1/player/rust/backpacks") + suspend fun backpack(@Query("serverId") serverId: String, @Query("steamId") steamId: String): RustBackpackDto +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt new file mode 100644 index 0000000..987c566 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt @@ -0,0 +1,111 @@ +/* + * 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.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.dto.RustServerResponse +import com.runicgateway.app.data.api.dto.RustWipeListDto +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` + * §17). + * + * **These paths are hardcoded, and that is the contract rather than a shortcut.** + * `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so + * the app cannot build `//servers` from what `GET /public/modules` + * reports. A capability answers one question — *is the module there* — and these + * five addresses are knowledge the app has because someone read the module's + * router, exactly as the nine `/uo/` paths in [NavPaths] are. + * + * Its own interface, not a section of [PublicApi], for the reason [EventsApi] is + * its own: these exist only where the Rust module is installed, and a backend + * running a different game answers none of them. + */ +interface RustApi { + + /** + * Every Rust server this site follows. + * + * Answers from the module's own tables and never from a live call to a game + * host, so it succeeds while every server in the fleet is off — a server + * nobody can reach comes back `online: false, stale: true` with everything it + * last said still attached. There is no failure case here for the game being + * down, only for the website being down. + */ + @GET("api/v1/public/rust/servers") + suspend fun getServers(): RustServerListDto + + /** + * One server, or a **404**. + * + * The only route under `/servers/{id}` that can say a server is not there: + * the four below answer an empty list for an id nobody configured, because an + * unknown server genuinely has no events and nobody online. A server an + * operator **disabled** answers the same 404 — switching one off is not + * switching it into a refusal. + */ + @GET("api/v1/public/rust/servers/{id}") + suspend fun getServer(@Path("id") id: String): RustServerResponse + + /** + * The feed, newest first. + * + * [kind] is comma-separated and [wipe] a wipe id; both are optional, and an + * **absent one must be absent rather than empty** — `?wipe=` asks for a wipe + * whose id is the empty string and answers nothing, with no error to notice. + * Retrofit drops a null `@Query` entirely, which is why these are nullable + * and never defaulted to `""`. + * + * The server serves a default-deny allowlist: moderation events, login + * attempts and anything carrying an IP address are stored and never returned + * here, whatever is asked for. + */ + @GET("api/v1/public/rust/servers/{id}/events") + suspend fun getEvents( + @Path("id") id: String, + @Query("kind") kind: String? = null, + @Query("wipe") wipe: String? = null, + @Query("limit") limit: Int? = null, + ): RustEventListDto + + /** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */ + @GET("api/v1/public/rust/servers/{id}/leaderboard") + suspend fun getLeaderboard( + @Path("id") id: String, + @Query("wipe") wipe: String? = null, + @Query("sort") sort: String? = null, + @Query("limit") limit: Int? = null, + ): RustLeaderboardDto + + /** Every wipe this server has had, newest first. */ + @GET("api/v1/public/rust/servers/{id}/wipes") + suspend fun getWipes(@Path("id") id: String): RustWipeListDto + + /** The presence board, which an unreachable server does not clear. */ + @GET("api/v1/public/rust/servers/{id}/online") + suspend fun getOnline(@Path("id") id: String): RustOnlineDto + + /** + * The map: where its picture is, the frame to draw it in, and which layers + * this viewer gets (Rust phase 14). A module older than phase 14 answers 404. + */ + @GET("api/v1/public/rust/servers/{id}/map") + suspend fun getMap(@Path("id") id: String): RustMapDto + + /** + * What moves, already cut down to this viewer on the server: a layer they + * may not see is absent. The module asks the game at most once per five + * seconds per server, however many viewers there are (D111). + */ + @GET("api/v1/public/rust/servers/{id}/map/live") + suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt index 9d5aaa6..507200f 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt @@ -128,10 +128,18 @@ data class RecoveryGenerateRequest(val currentPassword: String? = null) @Serializable data class RecoveryCodesDto(val recoveryCodes: List = emptyList()) -/** A linked external identity (`GET /auth/me/account/identities`). */ +/** + * A linked external identity (`GET /auth/me/account/identities`). [id] unlinks this + * one identity when a provider has several (a person may link more than one Steam + * account); [subject] is the person's own external id — for Steam the SteamID64 — + * and [display_name] the Steam persona name (null for other providers). + */ @Serializable data class LinkedIdentityDto( + val id: Int? = null, val provider: String = "", + val subject: String? = null, val email: String? = null, + val display_name: String? = null, val linked_at: String? = null, ) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt index 25e5d6b..097f341 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt @@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable * * [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never * the reader's. See [com.runicgateway.app.ui.events.eventTime]. + * + * [runId] is on a run and never on a projection, which has nothing committed to + * it. Core added it for Rust D125, so a map marker naming a run can find its + * event; a core older than that omits it, and the marker stays unlinked. */ @Serializable data class EventCalendarEntryDto( val kind: String = "run", + val runId: Long? = null, val title: String = "", val slug: String = "", val seriesName: String? = null, @@ -83,6 +88,24 @@ data class EventOccurrenceDto( val scope: String? = null, val phase: String? = null, val resultsPublishedAt: String? = null, + /** + * On the live occurrence only: how its steps stand, in the words their modules + * chose (MODULE_API 1.12.0; runicnpc D304). Core interprets none of it, and nor + * does the app: each line is a label with a count or a percent. + */ + val progress: List = emptyList(), +) + +/** + * One progress line: a [label] with [left] of [of] ("Bandits: 3 of 8 left"), or a + * [percent] ("The Juggernaut: 62%"). Never both. + */ +@Serializable +data class EventProgressDto( + val label: String = "", + val left: Int? = null, + val of: Int? = null, + val percent: Int? = null, ) /** diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt new file mode 100644 index 0000000..738dba9 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerRustDto.kt @@ -0,0 +1,205 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * DTOs for a player's own half of `module-rust` (`docs/modules/rust/PLAN.md` + * §19, §20, §22; M15). + * + * Two surfaces, and they are deliberately separate reads rather than one: + * + * * **the links** — which Steam accounts this website account holds. A link is + * fleet-wide, because a Steam account is one person on every server an + * operator runs, while stats are per server and per wipe. + * * **what the site has given them in game** — groups and direct grants, each + * already resolved to the servers its scope reaches. It is its own read + * because an entitlement is authored against the *website* account, so it + * exists before a Steam id does; the person who has just been given something + * and has not linked yet is exactly the one who needs to see both halves at + * once. + * + * Nothing here is a write except the code redemption. A grant a player could + * change would not be a grant. + */ + +/** `GET /player/rust/links` — the Steam accounts the caller holds. */ +@Serializable +data class RustLinkListDto( + val links: List = emptyList(), +) + +/** + * One linked Steam account. + * + * [name] is what the player was called in game when they linked — a display + * name only, and a Rust name changes on a whim. [serverId] is where the code was + * minted, which is not part of the identity but is where a support conversation + * starts. + */ +@Serializable +data class RustLinkDto( + val steamId: String = "", + val name: String? = null, + val serverId: String? = null, + val linkedAt: String? = null, +) + +/** `POST /player/rust/link` body — the six-character code `/link` gives in game. */ +@Serializable +data class RustLinkRequest(val code: String) + +/** + * `POST /player/rust/link` result. + * + * [already] is a second press of the button rather than an error: the code was + * good and that Steam id was already this caller's. + */ +@Serializable +data class RustLinkResultDto( + val linked: Boolean = false, + val link: RustLinkDto? = null, + val already: Boolean = false, +) + +/** `DELETE /player/rust/links/{steamId}` result. */ +@Serializable +data class RustUnlinkResultDto( + val unlinked: Boolean = false, +) + +/** + * `GET /player/rust/permissions` — what the site has given this player in game. + * + * [accounts] is how many Steam accounts they have linked, and it is on the + * envelope for one reason: zero is why an entitlement can be authored and reach + * nobody, and the screen has to be able to say that without inferring it. + */ +@Serializable +data class RustPlayerPermissionsDto( + val accounts: Int = 0, + val groups: List = emptyList(), + val grants: List = emptyList(), +) + +/** A rank the site holds for this player, and what it carries. */ +@Serializable +data class RustPlayerGroupDto( + val name: String = "", + val title: String = "", + val scope: String = "*", + val since: String? = null, + val permissions: List = emptyList(), + val reach: List = emptyList(), +) + +/** One permission held directly, without a rank. */ +@Serializable +data class RustPlayerGrantDto( + val permission: String = "", + val scope: String = "*", + val source: String = "admin", + val note: String? = null, + val since: String? = null, + val reach: List = emptyList(), +) + +/** + * One server an entitlement's scope reaches, and whether it is there yet. + * + * **The scope arithmetic is the server's.** A client handed `scope: "*"` would + * have to know what the fleet is to say anything useful, and then the rule + * exists in two places; the website resolves it and marks each server instead. + * + * [live] is the pushed ledger rather than the authored row: a grant is not a + * privilege in a game until a sync confirmed it. `false` covers every way it has + * not arrived — the server is offline, no loaded plugin registered the name, the + * store has never seen the account — and telling those apart is an operator's + * diagnosis, not a player's. + */ +@Serializable +data class RustReachDto( + val id: String = "", + val name: String = "", + val live: Boolean = false, +) + +/** + * `GET /player/rust/economics` — the caller's coin balances + * (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): every server, each linked + * Steam account. Economics is optional on a server, so one without it is listed + * with [RustEconomicsServerDto.economics] false rather than left out. + */ +@Serializable +data class RustEconomicsDto( + val servers: List = emptyList(), +) + +/** + * One server's balances. [startingKnown] false means the server's Economics + * config could not be read, and [startingBalance] is Economics' default. + */ +@Serializable +data class RustEconomicsServerDto( + val serverId: String = "", + val server: String = "", + val economics: Boolean = false, + val reason: String? = null, + val startingBalance: Double? = null, + val startingKnown: Boolean = true, + val live: Boolean = false, + val accounts: List = emptyList(), +) + +/** + * One linked account's balance on one server. [source] is `live` (the server + * answered just now), `last-known` (it did not, and this is the last balance it + * reported), or null with [balance] null when neither knows. + */ +@Serializable +data class RustBalanceDto( + val steamId: String = "", + val name: String? = null, + val balance: Double? = null, + val source: String? = null, +) + +/** + * `GET /player/rust/backpacks?serverId=&steamId=` — one of the caller's own + * Backpacks backpacks on one server (`docs/modules/rust/PLAN_REDESIGNS.md` + * §11.3.2), read live and never stored. [exists] false is an account that has + * never had one, and then [capacity], [pageSize] and [items] are absent. + */ +@Serializable +data class RustBackpackDto( + val serverId: String = "", + val server: String = "", + val steamId: String = "", + val exists: Boolean = false, + val capacity: Int = 0, + val pageSize: Int = 48, + val items: List = emptyList(), +) + +/** + * One stack. [slot] runs across pages; [display] is the game's English name + * (D340), and for a blueprint the name of what it makes, with [blueprint] that + * item's shortname (D341). [overflow] is a stack past the capacity, which moves + * to the owner's inventory the next time they open the backpack (D338). + * [contents] is one level deep: a weapon's attachments, a container's items. + */ +@Serializable +data class RustBackpackStackDto( + val slot: Int = 0, + val shortname: String? = null, + val display: String? = null, + val amount: Int = 0, + val itemId: Int? = null, + val blueprint: String? = null, + val skin: String? = null, + val name: String? = null, + val overflow: Boolean = false, + val contents: List = emptyList(), +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt new file mode 100644 index 0000000..dc63720 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -0,0 +1,248 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17, + * §18; M14). + * + * **Every one of these renders while the game is off**, which is the module's own + * promise and therefore this leg's: the website never calls a game server from a + * page, it answers from its own tables, and a server nobody can reach answers + * `online: false` with everything it last said still attached. Nothing here has + * an "unavailable" shape, because there is no such answer on this wire. + */ + +/** `GET /public/rust/servers` — every server this site follows. */ +@Serializable +data class RustServerListDto( + val servers: List = emptyList(), +) + +/** `GET /public/rust/servers/{id}` — one of them, or a 404. */ +@Serializable +data class RustServerResponse( + val server: RustServerDto = RustServerDto(), +) + +/** + * One Rust server and what it last reported. + * + * **[online] and [stale] are not the same fact and the screen needs both.** + * `online` is what the last frame said; `stale` is whether anything has arrived + * recently enough to believe it. The server computes `online` as *"the row says + * up AND the row is fresh"*, so a stale row can never claim a server is up — but + * `stale` still has to come through, because a fresh row saying "down" and a row + * nobody has written in an hour are different things to say to a reader. + * + * **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is + * not.** The module shipped a defect on exactly this in phase 3 and fixed it in + * phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it + * as "last reported" made an offline server claim it had just checked in, every + * thirty seconds, for as long as it stayed down. Only a frame moves + * `lastSeenAt`. The app must not repeat the mistake one tier along. + */ +@Serializable +data class RustServerDto( + val id: String = "", + val name: String = "", + val online: Boolean = false, + val players: Int = 0, + val maxPlayers: Int = 0, + val hostname: String? = null, + val level: String? = null, + val worldSize: Int? = null, + val seed: Long? = null, + /** The CURRENT wipe, from the state row rather than the newest ingested wipe. */ + val wipeId: String? = null, + val wipedAt: String? = null, + /** When a frame last arrived. What "last reported" means. */ + val lastSeenAt: String? = null, + /** When this module last wrote the row — a failed poll moves it too. */ + val updatedAt: String? = null, + val stale: Boolean = false, + /** + * When the server wipes next (module-rust phase 16, D130), or null. Null + * both when the operator set no schedule and against a module older than + * phase 16, which does not send the field — either way nothing is drawn. + */ + val nextWipe: RustNextWipeDto? = null, +) + +/** + * The next wipe, as the module computed it from the operator's schedule on this + * read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the + * server's own weekly or biweekly day) or `once` (a one-off date the operator + * set — a delayed or an extra wipe). An unknown word is shown like `rule`. + */ +@Serializable +data class RustNextWipeDto( + val at: String? = null, + val source: String? = null, +) + +/** + * `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. + * + * **Nothing names who is online by default** (org lead, 2026-09-22). Below the + * operator's presence audience — staff unless widened — the server withholds + * every item that says a named player was on (joins, deaths, chat, tallies) and + * says so with [presenceHidden]; [presenceAudience] is who CAN see them. The + * screen says it, so a thin feed reads as withheld rather than as a quiet server. + */ +@Serializable +data class RustEventListDto( + val events: List = emptyList(), + val presenceHidden: Boolean = false, + val presenceAudience: String? = null, +) + +/** + * One stored frame. + * + * **[frame] is deliberately untyped.** The module stores the whole frame the + * bridge plugin emitted and indexes only the columns it serves, so the fields + * differ per [kind] and a later protocol adds more. A sealed hierarchy here would + * have to be extended in this repo before a server running a newer plugin could + * say anything new, and the module's own rule is the opposite: an unknown kind + * renders as itself rather than being dropped. [RustFeed] is the one place that + * knows the field names. + * + * [t] is epoch milliseconds — the stamp the plugin put on the frame, not a + * database column, so it is a number here and an ISO string everywhere else on + * this wire. + */ +@Serializable +data class RustEventDto( + val id: Long = 0, + val kind: String = "", + val t: Long = 0, + val wipeId: String? = null, + val steamId: String? = null, + val frame: JsonObject = JsonObject(emptyMap()), +) { + /** + * One frame field as text, or null. + * + * **A JSON `null` answers null, not the four letters.** The plugin writes + * explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a + * primitive's `content` is the string `"null"` for every one of them, which + * would put the word into a killfeed line. An empty string answers null too: + * the callers here all mean "is there something to show". + */ + fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() } + + /** One frame field as a number, or null when it is absent, null or not one. */ + fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull() + + /** One frame field as a flag. Absent, null and anything non-boolean are all false. */ + fun flag(key: String): Boolean = primitive(key)?.content == "true" + + /** The raw element, for a caller that wants to decide for itself. */ + fun raw(key: String): JsonElement? = frame[key] + + private fun primitive(key: String): JsonPrimitive? = + (frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull } +} + +/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */ +@Serializable +data class RustLeaderboardDto( + val leaderboard: List = emptyList(), +) + +/** + * One player's standing. + * + * All-time is these same per-wipe rows summed rather than a second set of + * counters, so the two can never disagree — which is why a player who appears + * only in an older wipe **drops out** of the current one rather than reading + * zero. The screen must not fill that gap in with zeroes. + */ +@Serializable +data class RustLeaderboardRowDto( + val steamId: String = "", + val name: String? = null, + val kills: Int = 0, + val deaths: Int = 0, + val npcKills: Int = 0, + val structures: Int = 0, + val playtimeSec: Long = 0, + val lastSeen: String? = null, + /** + * The chat titles this player holds now (module-rust phase 17, M19) — the + * ones the game shows in chat, after the server's mode. They rank the + * CURRENT wipe whichever wipe the board is showing. Absent from an older + * module, which is why it defaults to empty: nothing is drawn. + */ + val titles: List = emptyList(), +) + +/** One chat title: its text, and the operator's `#rrggbb` colour for it. */ +@Serializable +data class RustTitleDto( + val text: String = "", + val color: String = "", +) + +/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */ +@Serializable +data class RustWipeListDto( + val wipes: List = emptyList(), +) + +/** + * One wipe. + * + * [wipeId] is derived by the bridge plugin from the save's creation time and + * stamped on every frame, so it is the same id the feed and the leaderboard are + * filtered by — which is what makes the per-wipe view navigable at all. + */ +@Serializable +data class RustWipeDto( + val wipeId: String = "", + val saveCreatedAt: String? = null, + val firstSeen: String? = null, + val lastSeen: String? = null, +) + +/** + * `GET /public/rust/servers/{id}/online` — who is on right now. + * + * Below the operator's presence audience the names are withheld: [hidden] is + * true, [players] is empty and [count] is still the real number — a count names + * nobody, and it is already on the server line. An empty list with [hidden] set + * must never render as "nobody is on". + */ +@Serializable +data class RustOnlineDto( + val players: List = emptyList(), + val hidden: Boolean = false, + val count: Int = 0, + val audience: String? = null, +) + +/** + * One row of the presence board. + * + * Read from the board the bridge re-sends on every connect and every minute, + * rather than counted from connect and disconnect events — so it is right even + * after the website has missed one. **An unreachable server does not clear it**, + * deliberately: these rows are still the best answer anybody has. Presented bare + * they read as *who is on right now*, which is the one thing an offline server + * cannot be saying, so the screen has to say which it is. + */ +@Serializable +data class RustPresenceDto( + val steamId: String = "", + val name: String? = null, + val sleeping: Boolean = false, + val connectedAt: String? = null, +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt new file mode 100644 index 0000000..c90b957 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt @@ -0,0 +1,183 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * DTOs for `module-rust`'s map (`docs/modules/rust/PLAN.md` §30, §31; M17). + * + * **Who may see what is decided on the server, and these shapes only carry the + * answer.** Each of the four layers has its own audience, and the module removes + * a layer the viewer may not see before it answers: the layer is *absent*, not + * empty and not flagged. That is why every layer on [RustMapLiveDto] is nullable. + * A null list is "not yours"; an empty one is "yours, and nothing is there". + * The app has no gate of its own, so it has none to get wrong. + */ + +/** `GET /public/rust/servers/{id}/map`: the picture, the frame, and which layers this viewer gets. */ +@Serializable +data class RustMapDto( + val serverId: String = "", + /** Changes on a wipe or a new seed. A live answer naming another key means a new map. */ + val mapKey: String? = null, + /** Null when the game has no picture: the layers are drawn on [RustMapGeometryDto.background]. */ + val picture: RustMapPictureDto? = null, + /** Null when the server has never described its map. Nothing can be placed without it. */ + val geometry: RustMapGeometryDto? = null, + /** Present only when the viewer may see the world layer. */ + val monuments: List? = null, + val layers: RustMapLayersDto = RustMapLayersDto(), + val mates: RustMapMatesDto = RustMapMatesDto(), + val pollMs: Long? = null, +) + +/** + * Where the picture is. + * + * [path] is relative to `/api/v1` and carries the picture's hash, so it is + * immutable: a hash that is no longer current is a 404, never the new bytes. + */ +@Serializable +data class RustMapPictureDto( + val path: String = "", + val source: String? = null, + val fetchedAt: String? = null, +) + +/** + * How a world position reaches a pixel (§30.3). [oceanMargin] is in pixels and is + * not scaled; [gridCells] and [gridCellSize] are the game's own grid (D119). + */ +@Serializable +data class RustMapGeometryDto( + val worldSize: Double = 0.0, + val oceanMargin: Double = 0.0, + val width: Double = 0.0, + val height: Double = 0.0, + val gridCells: Int = 0, + val gridCellSize: Double = 0.0, + val background: String? = null, +) + +@Serializable +data class RustMonumentDto( + val value: String = "", + val kind: String = "", + val label: String = "", + val grid: String? = null, + val x: Double = 0.0, + val z: Double = 0.0, +) + +@Serializable +data class RustMapLayersDto( + val world: RustMapLayerDto = RustMapLayerDto(), + val events: RustMapLayerDto = RustMapLayerDto(), + val players: RustMapLayerDto = RustMapLayerDto(), + val bases: RustMapLayerDto = RustMapLayerDto(), +) + +/** + * Whether this viewer gets one layer, and who does. A hidden layer never says + * what it holds. [cappedByPresence] is the players layer's alone: it is narrower + * than its own switch because who may see who is online is narrower (D113). + */ +@Serializable +data class RustMapLayerDto( + val visible: Boolean = false, + val audience: String? = null, + val cappedByPresence: Boolean = false, +) + +/** Whether this viewer gets their own position and their online clan mates' (D115). */ +@Serializable +data class RustMapMatesDto( + val visible: Boolean = false, + /** The server's switch. */ + val on: Boolean = false, + val linked: Boolean = false, + val signedIn: Boolean = false, +) + +/** + * `GET /public/rust/servers/{id}/map/live`: what moves, cut down to this viewer. + * + * [live] false means the game did not answer and [reason] says why; the picture + * stays up. Positions are never stored by the site, so there is no last-known + * answer to fall back on here the way the presence board has one. + */ +@Serializable +data class RustMapLiveDto( + val live: Boolean = false, + val reason: String? = null, + val mapKey: String? = null, + val world: List? = null, + val events: List? = null, + val players: List? = null, + val playersTruncated: Boolean = false, + val bases: List? = null, + val basesTruncated: Boolean = false, + val mates: List? = null, +) + +/** + * A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply`, `crate`, or a RunicNPC + * `boss` placed outside any event (runicnpc D302), with its [name]. + */ +@Serializable +data class RustMapWorldDto( + val kind: String = "", + val x: Double = 0.0, + val z: Double = 0.0, + /** A locked crate being hacked: seconds left. */ + val hackLeftSec: Int? = null, + val hacked: Boolean = false, + val name: String? = null, +) + +/** + * What one of this site's events placed: a `zone`, `crate` or `npc` (phase 13a). + * + * [runId] is core's run id **as a string**, because the plugin holds it as one. + * Core's own shapes carry it as a number, so the two are matched as text. + */ +@Serializable +data class RustMapEventDto( + val kind: String = "", + val runId: String? = null, + val x: Double = 0.0, + val z: Double = 0.0, + val radius: Double? = null, + /** A zone's name, or a RunicNPC NPC's (runicnpc stage 8). */ + val name: String? = null, + val prefab: String? = null, + /** A RunicNPC boss: drawn bigger (D303). Its health is not sent. */ + val boss: Boolean = false, + /** One of a boss's adds, placed by the boss rather than by a step. */ + val add: Boolean = false, +) + +/** + * A player on the players layer, or a mate. [self] is only ever set on a mate: + * one of the viewer's own accounts. + */ +@Serializable +data class RustMapPlayerDto( + val steamId: String = "", + val name: String? = null, + val x: Double = 0.0, + val z: Double = 0.0, + val sleeping: Boolean = false, + val online: Boolean = false, + val self: Boolean = false, +) + +/** A base: `tc` or `vending`. Positions only: no owner, no authorised list, no shop name. */ +@Serializable +data class RustMapBaseDto( + val kind: String = "", + val x: Double = 0.0, + val z: Double = 0.0, +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt index 7282d77..c1fb891 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt @@ -16,7 +16,7 @@ import kotlinx.serialization.Serializable /** * One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is - * the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a + * the provider kind (`google` | `discord` | `steam` | `oidc` | `oauth2`); the app renders a * button per provider from this list rather than hardcoding a set. [loginUrl] is * the *website* start path (unused by the app, which builds its own * `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly. diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt index cb5ebbc..c6702cd 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt @@ -56,8 +56,8 @@ class AccountRepository @Inject constructor( suspend fun identities(): ApiResult> = safeApiCall { api.identities() } - suspend fun unlinkIdentity(provider: String): ApiResult = - safeApiCall { api.unlinkIdentity(provider) } + suspend fun unlinkIdentity(provider: String, id: Int? = null): ApiResult = + safeApiCall { api.unlinkIdentity(provider, id) } // ── Trusted devices (TRUSTED_DEVICES_MFA.md) ─────────────────────────── diff --git a/app/src/main/java/com/runicgateway/app/data/repository/LinkedAccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/LinkedAccountRepository.kt new file mode 100644 index 0000000..0d202eb --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/LinkedAccountRepository.kt @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.result.ApiResult +import javax.inject.Inject +import javax.inject.Singleton + +/** A game module whose player surface can say whether this user has linked an account. */ +enum class LinkSource { RUST, SHARD } + +/** + * Which module's link read answers for this site (`docs/modules/rust/PLAN.md` D69). + * + * **Read off the capabilities, never assumed.** Before phase 11 the answer was + * always `module-uo`'s `/player/shard/accounts`, which does not exist on a Rust + * site. That call failed, the failure read as "not linked", and the raid alert + * was locked in every channel. + * + * A host that has **never** answered asks both, and a failed read is simply no + * link. A site runs one module (§24.5), so at most one of the two answers. That + * is the same fail-open direction [canUse] takes. A host that answered and named + * neither module has no link to ask about. + */ +fun linkSourcesFor(capabilities: SiteCapabilities?): List { + if (capabilities == null) return LinkSource.entries + return buildList { + if (Capability.RUST in capabilities) add(LinkSource.RUST) + if (Capability.SHARD in capabilities) add(LinkSource.SHARD) + } +} + +/** + * Whether the signed-in user holds at least one linked game account on this site. + * + * It exists for the settings screen's one courtesy gate: a `requiresLinkedAccount` + * stream cannot have push switched **on** without a link. It is a courtesy and not + * a boundary. The server enforces no such flag, and who is actually alerted is the + * module's recipient computation (Rust D59), so this never needs to be more than + * a best guess. + */ +@Singleton +class LinkedAccountRepository @Inject constructor( + private val capabilities: SiteCapabilitiesRepository, + private val rust: PlayerRustRepository, + private val shard: PlayerShardRepository, +) { + suspend fun hasLinkedAccount(): Boolean = + linkSourcesFor(capabilities.capabilities.value).any { source -> + when (source) { + LinkSource.RUST -> (rust.links() as? ApiResult.Ok)?.data?.isNotEmpty() == true + LinkSource.SHARD -> (shard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt new file mode 100644 index 0000000..d3883f4 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/PlayerRustRepository.kt @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.map +import com.runicgateway.app.core.result.safeApiCall +import com.runicgateway.app.data.api.PlayerRustApi +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustEconomicsServerDto +import com.runicgateway.app.data.api.dto.RustLinkDto +import com.runicgateway.app.data.api.dto.RustLinkRequest +import com.runicgateway.app.data.api.dto.RustLinkResultDto +import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto +import com.runicgateway.app.data.api.dto.RustServerDto +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A player's own Rust identity and what it earns them (PLAN.md §9 M15), over the + * bearer-gated `/player/rust/…` surface. + * + * Every call returns a typed [ApiResult] rather than throwing, like every other + * repository here — and on this surface the **status is the message**: the + * website answers a refused code `400`, a Steam account somebody else holds + * `409`, an unreachable server `503` and a capped attempt `429`, precisely so a + * client can tell a player what to do next without reading prose. The view model + * is where that mapping lives. + * + * Nothing is cached. The entitlement read in particular is a picture of what the + * site has confirmed into a game, and a stale copy of that would be the one kind + * of wrong answer this whole surface exists to avoid. + */ +@Singleton +class PlayerRustRepository @Inject constructor( + private val api: PlayerRustApi, +) { + /** The Steam accounts the caller holds, newest first. */ + suspend fun links(): ApiResult> = + safeApiCall { api.links() }.map { it.links } + + /** Redeem a code from `/link` in game. */ + suspend fun link(code: String): ApiResult = + safeApiCall { api.link(RustLinkRequest(code)) } + + /** Release one of the caller's own links. */ + suspend fun unlink(steamId: String): ApiResult = + safeApiCall { api.unlink(steamId) }.map { it.unlinked } + + /** Ranks and grants the site holds for the caller, resolved per server. */ + suspend fun permissions(): ApiResult = + safeApiCall { api.permissions() } + + /** The caller's coin balances per server and linked account (§11.2.2). */ + suspend fun economics(): ApiResult> = + safeApiCall { api.economics() }.map { it.servers } + + /** The servers the backpack section offers (§11.3.2). */ + suspend fun servers(): ApiResult> = + safeApiCall { api.servers() }.map { it.servers } + + /** One of the caller's own backpacks, read live (§11.3.2). Never cached: it is the game's state now. */ + suspend fun backpack(serverId: String, steamId: String): ApiResult = + safeApiCall { api.backpack(serverId, steamId) } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt new file mode 100644 index 0000000..7bd5779 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt @@ -0,0 +1,99 @@ +/* + * 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.RustEventListDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustWipeDto +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The Rust module's public read path (PLAN.md §9 M14). + * + * Every read unwraps its envelope here rather than in a view model, so no screen + * holds a `…Dto` whose only job was to carry one list. Nothing is cached and + * nothing is merged: the module's tables are already the cache — the site's whole + * premise is that it answers from what a server last said rather than from the + * server — so a second copy in the app would only add a way for the two to + * disagree. + */ +@Singleton +class RustRepository @Inject constructor( + private val api: RustApi, +) { + /** Every server this site follows, with what each last reported. */ + suspend fun servers(): ApiResult> = + safeApiCall { api.getServers() }.map { it.servers } + + /** One server. A 404 here means no such server, or one an operator disabled. */ + suspend fun server(id: String): ApiResult = + safeApiCall { api.getServer(id) }.map { it.server } + + /** + * The feed. + * + * [kinds] is joined here rather than by a caller, so the query string this + * app sends exists in one place — and an **empty** list is sent as no `kind` + * parameter at all, which asks for the whole allowlist. Sending `kind=` would + * ask for a kind named the empty string. + */ + suspend fun events( + id: String, + kinds: List = emptyList(), + wipe: String? = null, + limit: Int? = null, + ): ApiResult = safeApiCall { + api.getEvents( + id = id, + kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","), + wipe = wipe?.takeIf { it.isNotBlank() }, + limit = limit, + ) + } + + /** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */ + suspend fun leaderboard( + id: String, + wipe: String? = null, + sort: String? = null, + limit: Int? = null, + ): ApiResult> = safeApiCall { + api.getLeaderboard( + id = id, + wipe = wipe?.takeIf { it.isNotBlank() }, + sort = sort?.takeIf { it.isNotBlank() }, + limit = limit, + ) + }.map { it.leaderboard } + + /** Every wipe this server has had, newest first. */ + suspend fun wipes(id: String): ApiResult> = + safeApiCall { api.getWipes(id) }.map { it.wipes } + + /** + * The presence board. Rows survive an unreachable server, by design. + * + * Answered whole rather than as its rows: `hidden` and `count` are what let + * the screen tell "withheld from you" from "nobody is on". + */ + suspend fun online(id: String): ApiResult = + safeApiCall { api.getOnline(id) } + + /** The map's picture, frame and layer gates for this viewer. */ + suspend fun map(id: String): ApiResult = + safeApiCall { api.getMap(id) } + + /** What moves on the map, as this viewer may see it. */ + suspend fun mapLive(id: String): ApiResult = + safeApiCall { api.getMapLive(id) } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt index a5417a8..7963df5 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt @@ -159,6 +159,36 @@ 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" + + /** + * The Rust module's live map (`docs/modules/rust/PLAN.md` phase 14, D122). + * + * **A surface name, which [RUST]'s note warns against for a menu row** — and + * that warning is about a row reachable on any site. This gates one tab on + * the Rust server screen, which is reachable only where [RUST] already + * answered, so another module declaring `map` cannot reveal it anywhere the + * Rust module is absent. What it does answer is the one question that + * matters here: a module older than phase 14 does not declare it. + */ + const val MAP = "map" } diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 2e76578..ed3e7df 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -19,8 +19,10 @@ import com.runicgateway.app.data.api.EventsApi import com.runicgateway.app.data.api.MeApi import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.NotificationsApi +import com.runicgateway.app.data.api.PlayerRustApi import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PublicApi +import com.runicgateway.app.data.api.RustApi import com.runicgateway.app.data.api.SsoApi import dagger.Module import dagger.Provides @@ -132,6 +134,28 @@ 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) + + /** + * A player's own Rust identity and entitlements (§9 M15) — bearer-authed on + * the main client, like [providePlayerShardApi] one game along. + */ + @Provides + @Singleton + fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi = + retrofit.create(PlayerRustApi::class.java) + /** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */ @Provides @Singleton diff --git a/app/src/main/java/com/runicgateway/app/ui/Polling.kt b/app/src/main/java/com/runicgateway/app/ui/Polling.kt new file mode 100644 index 0000000..d5ba21e --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/Polling.kt @@ -0,0 +1,110 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.runicgateway.app.core.result.ApiResult +import kotlinx.coroutines.delay + +/** + * Repeated reads of a surface that changes while somebody is looking at it + * (`docs/modules/rust/PLAN.md` D14, and D17 for this leg). + * + * ## Why a refresh is not a load + * + * The app has had exactly one shape for a read until now: set [UiState.Loading], + * ask, replace. That is right for opening a screen and wrong for a poll — a + * twenty-second refresh built on it would clear the killfeed, put a spinner where + * it was and re-fill it, three times a minute, for ever. The website hit the same + * wall one tier along: core's `useAsync` blanks its data on every dependency + * change, so `module-rust` bundles its own `usePolled`. This is that hook's other + * half. + * + * The rule both ends keep: **a refresh is invisible when it succeeds, and keeps + * the rows when it fails.** A site whose whole premise is "it renders while the + * game is off" must not blank itself the first time a request does. + */ + +/** How often a live surface re-reads itself while somebody is looking at it (D17). */ +const val POLL_INTERVAL_MS = 20_000L + +/** + * What a poll produced: the state to render, and whether the last attempt failed. + * + * Two fields rather than a wider [UiState] because they are two facts and a + * screen renders them in different places — the rows in the list, the failure as + * a quiet line above it. Collapsing them would force the choice this exists to + * avoid: show the error and lose the rows, or keep the rows and say nothing. + */ +data class Polled( + val state: UiState = UiState.Loading, + /** True when the most recent refresh failed **and there were rows to keep**. */ + val refreshFailed: Boolean = false, +) + +/** + * Fold a refresh into what is already on screen. + * + * Three cases, and the middle one is the whole point: + * + * - **It answered.** The new data replaces the old and any previous failure + * clears. This is the ordinary path and it is silent. + * - **It failed, and there are rows.** The rows stay exactly as they are and the + * failure is reported beside them. Nothing is blanked and nothing is retried + * on the reader's behalf — the next tick is twenty seconds away. + * - **It failed, and there is nothing yet.** There is nothing to protect, so it + * becomes an ordinary error with a retry — which is what the first load + * failing means. + * + * Pure, and takes the current state rather than reading one, so the rule is + * tested without a dispatcher, a view model or Compose. + */ +fun refreshInto(current: UiState, result: ApiResult): Polled = when { + result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false) + current is UiState.Success -> Polled(current, refreshFailed = true) + else -> Polled(result.toUiState(), refreshFailed = false) +} + +/** + * Run [block] now and every [intervalMs] for as long as this screen is resumed. + * + * `repeatOnLifecycle` is what makes this the phone's version of D14's Page + * Visibility gate, and it gets three behaviours from one line: + * + * - **Nothing runs while the app is away.** The coroutine is cancelled at + * `onPause`, so a backgrounded app makes no requests at all — not a slower + * poll, none. + * - **Coming back refreshes immediately.** The block is restarted from the top + * at `onResume`, which calls [block] before the first [delay] — so the first + * thing a returning reader sees is current, not up to twenty seconds old. + * - **A dialog or the recents switcher pauses it**, because that is what RESUMED + * means. The alternative, STARTED, keeps polling behind a partially + * obscured screen, which is precisely the reader who is not reading. + * + * **Keyed on the lifecycle owner alone, and [block] is held through + * `rememberUpdatedState`.** Keying on the block would restart the loop on every + * recomposition, because a lambda is a new object each time; capturing it without + * `rememberUpdatedState` would freeze the *first* one, so a tab change or a + * newly chosen wipe would keep refreshing the question the reader has stopped + * asking. The loop is stable and what it calls is current. + */ +@Composable +fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + val current by rememberUpdatedState(block) + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (true) { + current() + delay(intervalMs) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index a90403e..b517a47 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -57,6 +57,7 @@ import com.runicgateway.app.core.auth.Session import com.runicgateway.app.core.web.WebHandoff import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.appearance.SiteAppearance +import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen @@ -101,6 +102,10 @@ 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.RustAccountScreen +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,7 +123,12 @@ 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, + // The player's own Rust account (M15) — a drawer row like the three above it. + Routes.PLAYER_RUST, Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, ) @@ -140,6 +150,7 @@ fun RunicApp( onDeepLinkConsumed: () -> Unit = {}, sessionViewModel: SessionViewModel = hiltViewModel(), inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(), + rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(), ) { val brand = appearance.brand val navController = rememberNavController() @@ -154,15 +165,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 +292,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 +418,7 @@ private fun NavRow( colors: NavigationDrawerItemColors, indented: Boolean = false, unread: Int = 0, + rustOnline: Int = 0, onClick: () -> Unit, ) { val route = when (node) { @@ -405,6 +438,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 +474,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 +628,28 @@ 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 }, + navArgument(Routes.Args.TAB) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { + RustServerScreen( + onBack = { navController.navigateTopLevel(Routes.RUST) }, + // D123: a site-event marker opens the app's own event page on its run. + onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) }, + ) + } composable(Routes.WIKI) { WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) }) } @@ -676,6 +753,12 @@ private fun RunicNavHost( composable(Routes.PLAYER_HOUSES) { PlayerGate(session, navController) { MyHousesScreen() } } + // The player's own Rust account (M15). Behind the same gate as the three + // above: the route is `requireAuth` server-side, and the gate is here so a + // signed-out reader is sent home rather than left on a screen that 401s. + composable(Routes.PLAYER_RUST) { + PlayerGate(session, navController) { RustAccountScreen() } + } // ── Staff operations (§1, §6.4, M10) — reached from the staff menu section. // The backend re-checks role on every /admin/… call; these gates only mirror diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index d3daebb..be8df3d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -329,15 +329,18 @@ private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: A verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { + val provider = identity.provider.replaceFirstChar { it.uppercase() } Text( - identity.provider.replaceFirstChar { it.uppercase() }, + // Steam: "Steam: CoolGuy99" — a person may hold several, so the name tells them apart. + identity.display_name?.let { stringResource(R.string.account_identity_named, provider, it) } ?: provider, style = MaterialTheme.typography.bodyLarge, ) - identity.email?.let { + // Steam sends no email; its SteamID64 identifies the account instead. + (identity.email ?: identity.subject?.takeIf { identity.provider == "steam" })?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) { + TextButton(onClick = { viewModel.unlinkIdentity(identity) }, enabled = !busy) { Text(stringResource(R.string.account_identity_unlink)) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt index 5d0a47d..411b310 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt @@ -158,11 +158,12 @@ class AccountViewModel @Inject constructor( } // ── Identities ─────────────────────────────────────────────────────── - fun unlinkIdentity(provider: String) { + /** Unlink [identity] alone — by its id, so one of several Steam accounts goes and the rest stay. */ + fun unlinkIdentity(identity: LinkedIdentityDto) { if (_state.value.busy) return _state.update { it.copy(busy = true, feedback = null) } viewModelScope.launch { - when (accountRepository.unlinkIdentity(provider)) { + when (accountRepository.unlinkIdentity(identity.provider, identity.id)) { is ApiResult.Ok -> { finish(Section.IDENTITY, true, R.string.account_identity_unlinked) when (val ids = accountRepository.identities()) { diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt index c9b0469..40ef0bd 100644 --- a/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt @@ -27,7 +27,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.EventOccurrenceDto import com.runicgateway.app.data.api.dto.EventParticipantDto +import com.runicgateway.app.data.api.dto.EventProgressDto import com.runicgateway.app.data.api.dto.PublicEventDto +import com.runicgateway.app.ui.PollWhileResumed import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.HtmlText @@ -62,10 +64,30 @@ fun EventScreen( when (val s = state) { is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) is UiState.Loading -> LoadingView(modifier) - is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier) + is UiState.Success -> { + // A live event's progress lines move (MODULE_API 1.12.0, runicnpc D304), + // so the page is re-read while it is live and on screen. + if (s.data.live) PollWhileResumed(intervalMs = PROGRESS_REFRESH_MS) { viewModel.refresh() } + EventBody(s.data, onOpenSeries, onOpenRun, modifier) + } } } +/** How often a live event's page is re-read, as the web page does. */ +private const val PROGRESS_REFRESH_MS = 15_000L + +/** + * One progress line as text: "Bandits: 3 of 8 left", or "The Juggernaut: 62%". + * Anything else renders nothing. Internal for the test. + */ +internal fun progressText(line: EventProgressDto, count: (String, Int, Int) -> String, percent: (String, Int) -> String): String? { + if (line.label.isBlank()) return null + val left = line.left + val of = line.of + if (left != null && of != null) return count(line.label, left, of) + return line.percent?.let { percent(line.label, it) } +} + @Composable private fun EventBody( event: PublicEventDto, @@ -196,6 +218,22 @@ private fun Headline(event: PublicEventDto) { style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) + // How it stands, in the words the steps' modules chose. + val countWords = stringResource(R.string.events_progress_count) + val percentWords = stringResource(R.string.events_progress_percent) + current.progress.forEach { line -> + progressText( + line, + count = { label, left, of -> countWords.format(label, left, of) }, + percent = { label, p -> percentWords.format(label, p) }, + )?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } event.next != null -> { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt index 0fa6d07..65906ed 100644 --- a/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt @@ -56,4 +56,16 @@ class EventViewModel @Inject constructor( _state.value = repository.event(slug, run).toUiState() } } + + /** + * Re-read while the event is live, for its progress lines (MODULE_API 1.12.0, + * runicnpc D304). Quiet: the page stays on screen, and a failed re-read keeps + * what is shown rather than replacing the page with an error. + */ + fun refresh() { + viewModelScope.launch { + val fresh = repository.event(slug, run).toUiState() + if (fresh is UiState.Success) _state.value = fresh + } + } } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 2c0056c..a3bb18e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -128,6 +128,17 @@ val APP_MENU: List = listOf( feature = ShardFeature.MARKET, capability = Capability.SHARD, ), + // The Rust module (M14). ONE row, because the module's whole public surface is + // one list and one page beneath it — `/rust` IS the server list, not a hub + // above one. + // + // **No `feature`, and that is not an omission.** The visibility framework is + // `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/` + // and none under core's), and §2.7 forbids a module importing another's — so + // `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds + // one; until then these routes are public to everyone the site is public to, + // and a `feature` here would be gating on a flag nothing publishes. + MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST), MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), @@ -166,11 +177,40 @@ val APP_MENU: List = listOf( MenuAccess.PLAYER, capability = Capability.SHARD, ), + // The player's own Rust account (M15). Beside the three UO rows above and + // gated exactly as they are: the module's `rust` capability (a row needs the + // code behind it INSTALLED) and `PLAYER` access, which is `isPlayer || + // isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone. + // + // **`rust`, not `identity`.** The module declares a surface word for each of + // its features, and D16's rule is that a capability answers one question — + // *is the module there* — so a surface word is not what a row hangs on. The + // paths these screens call are knowledge the app has from reading the + // module's router, exactly as §2.9 requires. + MenuEntry( + Routes.PLAYER_RUST, + R.string.menu_rust_account, + MenuAccess.PLAYER, + capability = Capability.RUST, + ), // Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF), MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), - MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR), - MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR), + // Moderation and the support queue act on the shard (`/admin/shard/*`, module-uo's + // admin mount), so they need the module installed like the player rows do. On a + // Rust-only site they once showed a UO form whose every action failed (M20). + MenuEntry( + Routes.ADMIN_MODERATION, + R.string.menu_admin_moderation, + MenuAccess.MODERATOR, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.ADMIN_SUPPORT, + R.string.menu_admin_support, + MenuAccess.MODERATOR, + capability = Capability.SHARD, + ), ) /** diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt index 69766cf..d993163 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.navigation import com.runicgateway.app.data.repository.ContentRepository.PostCategory +import com.runicgateway.app.ui.rust.RustTab /** * The website path → app route table (THEMING_AND_NAV.md §6.2). @@ -70,6 +71,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 +129,16 @@ val WEBSITE_PUBLIC_NAV: List = listOf( WebNavPath("/uo/atlas", Routes.ATLAS), WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS), WebNavPath("/uo/market", Routes.SHARD_MARKET), + // module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }` + // and nothing else — `/rust` IS the server list, because core strips the + // trailing separator from a module route registered with `path: ''`. + // + // **Both modules can be installed on one backend**, and then the nav is core's + // eight plus ten. This table is a superset by design: a path here for a module + // an operator has NOT installed never appears in that backend's nav and so is + // never looked up, while a path missing from it makes a link that exists hand + // off to a browser. + WebNavPath("/rust", Routes.RUST), ) /** @@ -178,7 +198,12 @@ private val RESERVED_TOP_LEVEL = setOf( // Only ids the app knows about need listing: an unknown module's `/` would // resolve to a CMS page that 404s, which is the same answer the browser gives // it, and core cannot enumerate them for us here anyway. - "uo", + // + // `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to + // the server list, and it does so through the nav table above — this set only + // stops the CMS-page fallback claiming it. Without the entry a site with the + // module absent would open a page-not-found screen instead of the browser. + "uo", "rust", ) /** @@ -209,6 +234,7 @@ private val RESERVED_TOP_LEVEL = setOf( * // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id, * // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market, * // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug + * // /rust, /rust/servers/:id * // CMS pages: top-level /:slug, matched only after the named routes above * } /> * ``` @@ -231,6 +257,9 @@ private val RESERVED_TOP_LEVEL = setOf( * /uo/ → the mapped shard route (§6.2) * /uo/atlas/ → ATLAS_CREATURE * /uo/market/vendors/ → SHARD_MARKET_VENDOR + * /rust → RUST (module-rust's server list) + * /rust/servers/[?tab=] → RUST_SERVER (a tab the app has; Rust D71) + * /player/rust → PLAYER_RUST (Rust D71) * /site/about → PAGE("about") * / → PAGE(slug), unless is reserved * anything else → null, i.e. the Custom Tab @@ -245,6 +274,12 @@ private val RESERVED_TOP_LEVEL = setOf( * string, any second parameter, and any fragment still hand off** — the carve-out * is one key on one path, not a general "parse the query". * + * **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path, + * which is what the *new leader* notice links to. It resolves only when the value + * is a tab the app has. `?tab=clans` still hands off, because the website has a + * Clans tab and the app does not, and opening the feed instead would be the quiet + * drop this rule exists to prevent. + * * That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets * the browser, which honors `utm`, rather than an app screen that silently ignored * it. @@ -282,6 +317,10 @@ fun resolveWebPath(path: String?): String? { val run = runParam(query) ?: return null return Routes.event(segments[2], run) } + if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) { + val tab = rustTabParam(query) ?: return null + return Routes.rustServer(segments[2], tab) + } if (query.isNotEmpty()) return null return when { @@ -295,6 +334,14 @@ 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]) + // The player's own Rust account, which the *account linked* notice links + // to (D71). Not in the nav table above, which is built from the PUBLIC + // nav; a signed-out tap is caught by the player gate like any other. + segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST else -> null } } @@ -315,6 +362,17 @@ private fun runParam(query: String): String? { return value.takeIf { '&' !in it && '=' !in it } } +/** + * The tab a query of **exactly** `tab=` names, when the app has that + * tab (Rust D71), or null, which hands the link to the browser. Same shape as + * [runParam], and for the same reasons. + */ +private fun rustTabParam(query: String): RustTab? { + val value = query.removePrefix("tab=") + if (value.length == query.length || value.isEmpty() || '&' in value || '=' in value) return null + return RustTab.fromWire(value) +} + /** * The module id whose public pages this table maps. * @@ -322,3 +380,6 @@ private fun runParam(query: String): String? { * module is countable. It is a literal on purpose — see the file header. */ private const val MODULE_UO = "uo" + +/** The second game module's id (M14). A literal for the same reason. */ +private const val MODULE_RUST = "rust" diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index c21ad96..68b3f9c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.navigation import com.runicgateway.app.data.repository.ContentRepository +import com.runicgateway.app.ui.rust.RustTab /** * Navigation destinations for the M1 public surface (PLAN.md §5). Routes are @@ -70,6 +71,33 @@ 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}?tab={tab}" + + /** + * The player's own Rust account (§9 M15) — the Steam accounts they hold and + * what the site has given them in game. + * + * Under `player/` with the three UO rows rather than under `rust/` with the + * two public ones, because the grouping that matters to a reader is *whose + * data is this*: these are the signed-in, self-scoped screens. The website + * agrees — it serves this from the player tier, at `/player/rust`. + */ + const val PLAYER_RUST = "player/rust" + /** Public shard hub (§6.2). */ const val SHARD = "shard" @@ -121,6 +149,8 @@ object Routes { const val ID_OR_SLUG = "idOrSlug" const val SERIAL = "serial" const val RUN = "run" + const val SERVER_ID = "serverId" + const val TAB = "tab" } fun page(slug: String) = "page/$slug" @@ -140,6 +170,48 @@ 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. + * + * [tab] is what the *new leader* notice's link carries (Rust D71), and it is + * dropped when absent, as `run` is on [event]. + */ + fun rustServer(id: String, tab: RustTab? = null): String { + val base = "rust/servers/${encodePathSegment(id)}" + return if (tab == null) base else "$base?tab=${tab.wire}" + } + + /** + * Percent-encode one path segment, allowing only the unreserved set. + * + * Deliberately stricter than it needs to be: encoding a character that did + * not need it still round-trips, where missing one that did produces a route + * NavHost matches differently from the one that was built. UTF-8 first, so a + * non-ASCII name is encoded per byte rather than per character. + */ + private fun encodePathSegment(value: String): String = buildString { + for (byte in value.toByteArray(Charsets.UTF_8)) { + val code = byte.toInt() and 0xFF + val char = code.toChar() + if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) { + append(char) + } else { + append('%').append(code.toString(16).uppercase().padStart(2, '0')) + } + } + } + + private const val UNRESERVED = "-._~" + /** One creature's atlas page, by slug. */ fun atlasCreature(slug: String) = "atlas/$slug" diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt index 0a1faf8..5d01498 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt @@ -153,7 +153,7 @@ private fun ChannelPrefsList( SectionLabel(stringResource(R.string.notifications_section_general)) Spacer(Modifier.height(8.dp)) general.forEach { item -> - ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode) + ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode) HorizontalDivider() } Spacer(Modifier.height(20.dp)) @@ -163,15 +163,7 @@ private fun ChannelPrefsList( SectionLabel(stringResource(R.string.notifications_section_personal)) Spacer(Modifier.height(8.dp)) personal.forEach { item -> - val selectable = itemSelectable(item, hasLinkedAccount) - ItemRow( - item = item, - channelsById = channelsById, - hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null, - enabled = !busy && selectable, - pushSupported = pushSupported, - onSetMode = onSetMode, - ) + ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode) HorizontalDivider() } } @@ -181,33 +173,40 @@ private fun ChannelPrefsList( private fun ItemRow( item: NotificationChannelItemDto, channelsById: Map, - hint: String?, - enabled: Boolean, + hasLinkedAccount: Boolean, + busy: Boolean, pushSupported: Boolean, onSetMode: (NotificationChannelItemDto, String, String) -> Unit, ) { + // The link holds back one control, push switching on, so the row stays live + // and the hint names that one control (Rust D69). Where this device has no + // push at all, there is no control to hold back and nothing to explain. + val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount) Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) { Text( text = item.label, style = MaterialTheme.typography.bodyLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurface, ) Text( - text = hint ?: item.description, + text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal, + fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal, ) // The item's OWN channel list, in the registry's order. An id nothing can // push carries no push control at all, rather than a dead switch. item.channels.forEach { channelId -> val channel = channelsById[channelId] ?: return@forEach if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach + val mode = item.modes[channelId] ?: channel.defaultMode ChannelControl( channel = channel, - mode = item.modes[channelId] ?: channel.defaultMode, - enabled = enabled, - onSetMode = { mode -> onSetMode(item, channelId, mode) }, + mode = mode, + // A held push switch that is already ON stays enabled, because + // switching it off is never refused ([canSetMode]). + enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF), + onSetMode = { next -> onSetMode(item, channelId, next) }, ) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt index b4dc15a..01ae3fb 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt @@ -14,7 +14,7 @@ import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.data.api.dto.NotificationChannelItemDto import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto import com.runicgateway.app.data.repository.NotificationsRepository -import com.runicgateway.app.data.repository.PlayerShardRepository +import com.runicgateway.app.data.repository.LinkedAccountRepository import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.toUiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -53,7 +53,7 @@ const val MODE_OFF = "off" @HiltViewModel class NotificationSettingsViewModel @Inject constructor( private val notifications: NotificationsRepository, - private val playerShard: PlayerShardRepository, + private val linkedAccounts: LinkedAccountRepository, private val pushManager: PushManager, sessionManager: SessionManager, ) : ViewModel() { @@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor( data class State( val prefs: UiState = UiState.Loading, - /** Whether the user has ≥1 linked game account — personal streams need it. */ + /** + * Whether the user has ≥1 linked game account on this site's module, asked + * of that module (Rust D69). It only gates switching push ON (see [canSetMode]). + */ val hasLinkedAccount: Boolean = false, /** Whether this shard advertises a push relay at all (else the screen says so). */ val supported: Boolean = true, @@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor( _state.update { it.copy(prefs = UiState.Loading) } viewModelScope.launch { _state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) } - // A linked game account gates the personal streams; failure → treat as none. - val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true - _state.update { it.copy(hasLinkedAccount = linked) } + _state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) } } } @@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor( fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) { val current = _state.value if (current.busy) return - if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return + if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return _state.update { it.copy(busy = true, feedback = null) } viewModelScope.launch { @@ -160,8 +161,28 @@ class NotificationSettingsViewModel @Inject constructor( } /** - * Whether an item's controls are selectable for a user: a personal stream needs a - * linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose. + * Whether this item's push is held back for want of a linked game account + * (PLAN.md §11, `docs/modules/rust/PLAN.md` D69). + * + * **Push only.** Until phase 11 the whole row went dead, every channel, which + * made a personal stream impossible to switch *off* on any site where the link + * check failed. That was every Rust site, because the check asked `module-uo`. + * In-app and email have nothing to do with the link, and neither does turning + * anything off. Pure so the gating is unit-tested without Compose. */ -fun itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean = - !item.requiresLinkedAccount || hasLinkedAccount +fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean = + item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels + +/** + * Whether [mode] may be set on [channel] for [item]. + * + * The one thing refused is switching push **on** for an item [pushNeedsLink] holds + * back. Switching anything off is never refused: a gate that can trap a switch in + * the on position is worse than no gate. + */ +fun canSetMode( + item: NotificationChannelItemDto, + channel: String, + mode: String, + hasLinkedAccount: Boolean, +): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount)) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/BackpackLayout.kt b/app/src/main/java/com/runicgateway/app/ui/rust/BackpackLayout.kt new file mode 100644 index 0000000..3e8f33d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/BackpackLayout.kt @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustBackpackStackDto + +/** + * A backpack as a grid (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2) — the + * same rules as the website's `lib/backpack.js`, pure so the tests can ask them. + * + * A stack's slot runs across pages: page two of a 96-slot backpack starts at + * slot 48 when the page size is 48. A stack at or past the capacity is + * overflow, waiting for the owner's next open to be moved out (D338), so the + * grid draws those slots too, after the backpack's own, marked. + */ +data class BackpackCell(val slot: Int, val stack: RustBackpackStackDto?, val overflow: Boolean) + +data class BackpackPage(val index: Int, val cells: List) + +/** Enough slots for the capacity and every stack; a backpack that does not exist is no pages. */ +fun backpackPages(backpack: RustBackpackDto?): List { + if (backpack == null || !backpack.exists) return emptyList() + + val capacity = backpack.capacity.coerceAtLeast(0) + val pageSize = backpack.pageSize.coerceAtLeast(1) + val bySlot = backpack.items.associateBy { it.slot } + val furthest = backpack.items.maxOfOrNull { it.slot + 1 } ?: 0 + val total = maxOf(capacity, furthest) + + return (0 until total step pageSize).mapIndexed { index, start -> + val end = minOf(start + pageSize, total) + BackpackPage(index, (start until end).map { slot -> BackpackCell(slot, bySlot[slot], slot >= capacity) }) + } +} + +/** + * What a slot says (D340): the game's name, "… blueprint" for a blueprint + * (D341), then the shortname, then the bare item id for an item the game no + * longer knows. + */ +fun stackLabel(stack: RustBackpackStackDto?): String { + if (stack == null) return "" + val name = stack.display ?: stack.shortname ?: stack.itemId?.let { "item $it" } ?: "unknown item" + return if (stack.blueprint != null) "$name blueprint" else name +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt b/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt new file mode 100644 index 0000000..d527711 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.repository.EventsRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Which event a run on the map belongs to (`docs/modules/rust/PLAN.md` D123, D125). + * + * A site-event marker carries core's run id and nothing else about its event, and + * the app's event page is addressed by the event's slug. Core's public calendar + * maps one to the other: each `run` entry names its run (D125) and its slug. A + * live run is in the calendar's default window however long ago it started, so + * **one read of the default window** holds every run a marker could name that the + * public may see. + * + * **What is absent stays unlinked, and that is the gate.** Rehearsals and + * unlisted events are not on the public calendar, so a rehearsal's zone resolves + * to nothing and its card says only *site event*. There is no second rule here + * for which runs to hide. + * + * **It re-reads at most once a [refreshMs], and only for an id it does not + * know.** A map with one zone on it asks the calendar once, not every ten + * seconds; a new run appearing mid-session is found within a minute. A failed + * read keeps what the last one found and still counts as a read, so a site whose + * calendar is down is asked once a minute rather than on every poll. + * + * The calendar is the same for every viewer, so nothing here is per account. + */ +class EventRunResolver( + private val events: EventsRepository, + private val now: () -> Long = System::currentTimeMillis, + private val refreshMs: Long = REFRESH_MS, +) { + private val mutex = Mutex() + private var slugs: Map = emptyMap() + private var titles: Map = emptyMap() + private var readAt: Long? = null + + /** + * The event title for each of [runIds] the last read found (runicnpc D303: an NPC's + * card names its event). Never reads by itself: [resolve] decides when the calendar + * is asked, and this answers from what it found. + */ + suspend fun titles(runIds: Set): Map = mutex.withLock { + runIds.mapNotNull { id -> titles[id]?.let { id to it } }.toMap() + } + + /** + * The slug for each of [runIds] the public calendar lists. Ids it does not + * list are absent from the answer. + */ + suspend fun resolve(runIds: Set): Map = mutex.withLock { + val unknown = runIds.any { it !in slugs } + val last = readAt + if (unknown && (last == null || now() - last >= refreshMs)) read() + runIds.mapNotNull { id -> slugs[id]?.let { id to it } }.toMap() + } + + private suspend fun read() { + readAt = now() + val result = events.calendar() + if (result is ApiResult.Ok) { + // Core sends the id as a number and the plugin as a string, so they + // meet as text. + val runs = result.data.entries.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() } + slugs = runs.associate { it.runId.toString() to it.slug } + titles = runs.filter { it.title.isNotBlank() }.associate { it.runId.toString() to it.title } + } + } + + companion object { + const val REFRESH_MS = 60_000L + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt b/app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt new file mode 100644 index 0000000..224acd6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt @@ -0,0 +1,243 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121). + * + * Ported from the web's `mapGeometry.js`, with its test cases, so the phone and + * the page cannot place the same crate in two squares. Rust's world is centred on + * the origin, x east and z north. The picture is the world at a scale, with an + * ocean margin around it that is measured in **pixels** and is not scaled (the + * rig's 3 000 m map is 3 000 × 0.5 + 2 × 500 = 2 500 px). So: + * + * s = (width − 2 × margin) / worldSize + * px = (x + worldSize / 2) × s + margin + * py = (z + worldSize / 2) × s + margin measured UP from the bottom edge + * + * The web measures `py` up because Leaflet's simple frame grows north. A canvas + * grows **down**, so [toPixel] answers `height − py`, and that flip happens here + * and nowhere else. + * + * The grid is the **game's** (D119): [RustMapGeometryDto.gridCells] cells of + * [RustMapGeometryDto.gridCellSize] metres per side, lettered from the west and + * numbered from the north, `A0` at the north-west corner. Nothing here assumes a + * cell size, and nothing here assumes the rig's world size either. + */ +class MapFrame(val geometry: RustMapGeometryDto) { + + /** Picture pixels per world metre, or 0 for a geometry that cannot place anything. */ + val scale: Double = if (geometry.worldSize > 0 && geometry.width > 0) { + (geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize + } else { + 0.0 + } + + /** True when positions can be placed at all. */ + val canPlace: Boolean get() = scale > 0 + + val width: Double get() = geometry.width + val height: Double get() = geometry.height + + private val half: Double get() = geometry.worldSize / 2 + + /** A world position as a point in the picture's pixels, y measured down from the top. */ + fun toPixel(x: Double, z: Double): PicturePoint = PicturePoint( + x = (x + half) * scale + geometry.oceanMargin, + y = geometry.height - ((z + half) * scale + geometry.oceanMargin), + ) + + /** A distance on the ground as picture pixels: a zone's radius. */ + fun metres(m: Double): Double = m * scale + + /** The grid label for a world position, the way the in-game map writes it; null without a grid. */ + fun gridLabel(x: Double, z: Double): String? { + val g = geometry + if (g.gridCells <= 0 || g.gridCellSize <= 0) return null + val col = floor((x + half) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1) + val row = floor((half - z) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1) + return "${column(col)}$row" + } + + /** + * The grid in world metres: [MapGrid.lines] as pairs of ends, and a label at + * each cell's north-west corner. + */ + fun grid(): MapGrid { + val g = geometry + if (g.gridCells <= 0 || g.gridCellSize <= 0) return MapGrid(emptyList(), emptyList()) + val n = g.gridCells + val c = g.gridCellSize + val lines = buildList { + for (i in 0..n) { + val at = -half + i * c + add(WorldLine(at, half, at, half - n * c)) + add(WorldLine(-half, half - i * c, -half + n * c, half - i * c)) + } + } + val labels = buildList { + for (col in 0 until n) { + for (row in 0 until n) { + add(GridLabel("${column(col)}$row", -half + col * c, half - row * c)) + } + } + } + return MapGrid(lines, labels) + } + + companion object { + /** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */ + fun column(index: Int): String { + val name = StringBuilder() + var n = index + 1 + while (n > 0) { + val r = (n - 1) % 26 + name.insert(0, ('A' + r)) + n = (n - 1) / 26 + } + return name.toString() + } + } +} + +/** A point in the picture's own pixels, y down. */ +data class PicturePoint(val x: Double, val y: Double) + +/** A line in world metres, from (x1, z1) to (x2, z2). */ +data class WorldLine(val x1: Double, val z1: Double, val x2: Double, val z2: Double) + +/** A grid label and the world position of its cell's north-west corner. */ +data class GridLabel(val text: String, val x: Double, val z: Double) + +data class MapGrid(val lines: List, val labels: List) + +/** + * The picture's address, relative to the site's base. + * + * The module hands out `path` relative to `/api/v1`, as it does every path. It + * is joined here as **relative** (`api/v1/…`, no leading slash), the way every + * Retrofit path in this app is written, so a site served under a prefix keeps + * the prefix when [com.runicgateway.app.ui.LocalAssetResolver] resolves it. + */ +fun mapPictureUrl(path: String): String = "api/v1/" + path.trimStart('/') + +/** Seconds as `m:ss`, for a locked crate's hack. */ +fun countdown(seconds: Number?): String { + val s = max(0, (seconds?.toDouble() ?: 0.0).roundToInt()) + return "${s / 60}:${(s % 60).toString().padStart(2, '0')}" +} + +/** + * Where the picture sits on screen: screen pixels per picture pixel, and the + * screen position of the picture's top-left corner. + * + * Pure, so the gesture arithmetic is tested without Compose. **Zoom runs from + * fit-to-screen to [MAX_ZOOM] screen pixels per picture pixel** (§31.2), and a + * pan may take the view a quarter of the picture past its edges: some things sail + * off the edge of the world, and the rig's cargo ship spent phase 14's probe + * outside the picture entirely. + */ +data class MapTransform(val scale: Float, val offsetX: Float, val offsetY: Float) { + + fun screenX(pictureX: Double): Float = (pictureX * scale).toFloat() + offsetX + fun screenY(pictureY: Double): Float = (pictureY * scale).toFloat() + offsetY + + /** The picture pixel under a screen point. */ + fun pictureX(screenX: Float): Double = ((screenX - offsetX) / scale).toDouble() + fun pictureY(screenY: Float): Double = ((screenY - offsetY) / scale).toDouble() + + /** + * One step of a pinch: zoom by [zoom] about [centroidX], [centroidY] and move + * by [panX], [panY], then clamp to what the view allows. + */ + fun transformed( + view: ViewSize, + picture: ViewSize, + centroidX: Float, + centroidY: Float, + panX: Float, + panY: Float, + zoom: Float, + ): MapTransform { + val next = (scale * zoom).coerceIn(minScale(view, picture), maxScale(view, picture)) + val factor = next / scale + return MapTransform( + scale = next, + offsetX = centroidX - (centroidX - offsetX) * factor + panX, + offsetY = centroidY - (centroidY - offsetY) * factor + panY, + ).clamped(view, picture) + } + + /** + * The same view in a box of a different size: the picture pixel that was at + * the centre stays at the centre, and the zoom is clamped to what the new box + * allows. + * + * **Not a refit.** The walk found the reader's zoom thrown away when the + * status line under the map went from two lines to one: the map's box grew by + * a line's height, and a rule that refitted on every new size could not tell + * that from a rotation. A rotation keeps its centre too, which is what a + * reader turning the phone to see more of the same place wants. + */ + fun resized(from: ViewSize, to: ViewSize, picture: ViewSize): MapTransform { + val centreX = pictureX(from.width / 2) + val centreY = pictureY(from.height / 2) + val s = scale.coerceIn(minScale(to, picture), maxScale(to, picture)) + return MapTransform( + scale = s, + offsetX = to.width / 2 - (centreX * s).toFloat(), + offsetY = to.height / 2 - (centreY * s).toFloat(), + ).clamped(to, picture) + } + + /** + * Keep the view's centre within the picture plus a quarter of it on every + * side, so a reader can follow something off the edge and cannot lose the map. + */ + fun clamped(view: ViewSize, picture: ViewSize): MapTransform { + val cx = view.width / 2 + val cy = view.height / 2 + val w = picture.width * scale + val h = picture.height * scale + return copy( + offsetX = offsetX.coerceIn(cx - w * (1 + OVERSCROLL), cx + w * OVERSCROLL), + offsetY = offsetY.coerceIn(cy - h * (1 + OVERSCROLL), cy + h * OVERSCROLL), + ) + } + + companion object { + /** Screen pixels per picture pixel at the closest zoom. */ + const val MAX_ZOOM = 4f + + /** How far past its edges the view may be taken, as a share of the picture. */ + const val OVERSCROLL = 0.25f + + /** The whole picture, centred. */ + fun fit(view: ViewSize, picture: ViewSize): MapTransform { + val s = minScale(view, picture) + return MapTransform( + scale = s, + offsetX = (view.width - picture.width * s) / 2, + offsetY = (view.height - picture.height * s) / 2, + ) + } + + fun minScale(view: ViewSize, picture: ViewSize): Float { + if (picture.width <= 0 || picture.height <= 0) return 1f + return min(view.width / picture.width, view.height / picture.height) + } + + /** Never less than fit: a tiny picture on a large screen still fits. */ + fun maxScale(view: ViewSize, picture: ViewSize): Float = max(MAX_ZOOM, minScale(view, picture)) + } +} + +/** A width and height in pixels. */ +data class ViewSize(val width: Float, val height: Float) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt b/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt new file mode 100644 index 0000000..5eb4166 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt @@ -0,0 +1,171 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import kotlin.math.hypot +import kotlin.math.max + +/** + * The switches on the legend. [GRID] is the reader's own and always offered; + * [MATES] is offered only when the server sends the viewer's own position. + */ +enum class MapLayer { GRID, WORLD, EVENTS, PLAYERS, BASES, MATES } + +/** + * One thing drawn on the map, in world metres, carrying only what its layer sent. + * + * One flat shape rather than one per layer so that drawing, tapping and the card + * all read the same list, and a marker can never be tappable in a place it is not + * drawn. Nothing here is looked up: a player's name is the one the players layer + * carried to a viewer entitled to it (§31.4). + */ +data class MapMarker( + val layer: MapLayer, + /** `monument`, a world kind (`cargo`, `crate`…), an event kind (`zone`, `npc`…), `tc`, `vending`, `player` or `mate`. */ + val kind: String, + val x: Double, + val z: Double, + /** A monument's label, a zone's name, a player's or mate's name. */ + val name: String? = null, + /** A monument's grid square as the game wrote it; others are computed from the frame. */ + val grid: String? = null, + /** Core's run id, for something an event placed. */ + val runId: String? = null, + /** A zone's reach, in metres on the ground. */ + val radiusMetres: Double? = null, + val hackLeftSec: Int? = null, + val hacked: Boolean = false, + val online: Boolean = false, + val sleeping: Boolean = false, + val self: Boolean = false, + /** A RunicNPC boss, in an event or the world layer: drawn bigger (runicnpc D303). */ + val boss: Boolean = false, + /** One of a boss's adds. */ + val add: Boolean = false, +) { + /** A zone is ground, not a point: it is drawn and tapped as an area. */ + val isZone: Boolean get() = layer == MapLayer.EVENTS && kind == "zone" +} + +/** + * Every marker to draw, in drawing order, bottom first: monuments, world events, + * site events, bases, players, and the viewer's own and their mates' on top. + * + * A layer is drawn when the server sent it **and** the reader has not switched it + * off. A layer the server did not send is null on [live] and contributes nothing, + * which is the whole gate; there is no second check here to disagree with it. + */ +fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set): List = buildList { + if (MapLayer.WORLD in shown) { + map.monuments.orEmpty().forEach { + add(MapMarker(MapLayer.WORLD, "monument", it.x, it.z, name = it.label, grid = it.grid)) + } + live?.world.orEmpty().forEach { + add( + MapMarker( + MapLayer.WORLD, + it.kind, + it.x, + it.z, + name = it.name, + hackLeftSec = it.hackLeftSec, + hacked = it.hacked, + boss = it.kind == "boss", + ), + ) + } + } + if (MapLayer.EVENTS in shown) { + live?.events.orEmpty().forEach { + add( + MapMarker( + MapLayer.EVENTS, + it.kind, + it.x, + it.z, + name = it.name, + runId = it.runId?.takeIf { id -> id.isNotBlank() }, + radiusMetres = it.radius, + boss = it.boss, + add = it.add, + ), + ) + } + } + if (MapLayer.BASES in shown) { + live?.bases.orEmpty().forEach { add(MapMarker(MapLayer.BASES, it.kind, it.x, it.z)) } + } + if (MapLayer.PLAYERS in shown) { + live?.players.orEmpty().forEach { + add( + MapMarker( + MapLayer.PLAYERS, + "player", + it.x, + it.z, + name = it.name?.takeIf { n -> n.isNotBlank() } ?: it.steamId, + online = it.online, + sleeping = it.sleeping, + ), + ) + } + } + if (MapLayer.MATES in shown) { + live?.mates.orEmpty().forEach { + add( + MapMarker( + MapLayer.MATES, + "mate", + it.x, + it.z, + name = it.name?.takeIf { n -> n.isNotBlank() }, + online = it.online, + sleeping = it.sleeping, + self = it.self, + ), + ) + } + } +} + +/** + * The marker a tap at ([tapX], [tapY]) on screen means, or null. + * + * **A point wins over a zone.** The nearest point within [reachPx] is the answer; + * only when there is none does a zone answer, and then one whose ground the tap + * is on or within reach of. Otherwise a zone drawn round a monument would swallow + * every tap on the monument, and on the players standing in it. Among equals the + * one drawn last, which is the one on top, wins. + */ +fun nearestMarker( + markers: List, + frame: MapFrame, + transform: MapTransform, + tapX: Float, + tapY: Float, + reachPx: Float, +): MapMarker? { + fun distance(m: MapMarker): Double { + val p = frame.toPixel(m.x, m.z) + val d = hypot(transform.screenX(p.x) - tapX.toDouble(), transform.screenY(p.y) - tapY.toDouble()) + if (!m.isZone) return d + val radius = frame.metres(m.radiusMetres ?: 0.0) * transform.scale + return max(0.0, d - radius) + } + + fun closest(candidates: List): MapMarker? = candidates + .asReversed() + .map { it to distance(it) } + .filter { it.second <= reachPx } + .minByOrNull { it.second } + ?.first + + return closest(markers.filterNot { it.isZone }) ?: closest(markers.filter { it.isZone }) +} + +/** The distinct run ids the events layer names, for [EventRunResolver]. */ +fun eventRunIds(live: RustMapLiveDto?): Set = + live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet() diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt new file mode 100644 index 0000000..e13da8d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountScreen.kt @@ -0,0 +1,542 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustEconomicsServerDto +import com.runicgateway.app.data.api.dto.RustLinkDto +import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto +import com.runicgateway.app.data.api.dto.RustReachDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard +import com.runicgateway.app.ui.components.StatusPill + +/** + * The player's own Rust account (PLAN.md §9 M15) — the app's mirror of the + * module's `/player/rust` page, and the same shape as + * [com.runicgateway.app.ui.player.CharactersScreen] one game along: the code + * card first, then what the code got them. + * + * Two independent reads. The accounts can fail with the entitlements on screen, + * and the other way round, because an entitlement is authored against the + * website account and exists before a Steam id does. + */ +@Composable +fun RustAccountScreen( + modifier: Modifier = Modifier, + viewModel: RustAccountViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + LinkCard(state, viewModel) + + when (val links = state.links) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(links.kind, onRetry = viewModel::load) + is UiState.Success -> { + if (links.data.isEmpty()) { + Text( + stringResource(R.string.rust_account_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + links.data.forEach { link -> + LinkRow( + link = link, + busy = state.unlinking == link.steamId, + onUnlink = { viewModel.unlink(link.steamId) }, + ) + } + + Text( + stringResource(R.string.rust_account_fleet_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Text( + stringResource(R.string.rust_held_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + + when (val held = state.held) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load) + is UiState.Success -> Held(held.data) + } + + // Coins (PLAN_REDESIGNS §11.2.2). A 404 is a module older than protocol + // 14, which has no balances to give: the section is left out rather than + // shown as an error the player can do nothing about. + val coins = state.coins + if (!(coins is UiState.Error && coins.httpStatus == 404)) { + Text( + stringResource(R.string.rust_coins_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + + when (coins) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(coins.kind, onRetry = viewModel::load) + is UiState.Success -> Coins(coins.data) + } + + // Backpacks (PLAN_REDESIGNS §11.3.2) shipped in the same protocol + // as Economics, so the coins read's 404 gates both (R10). + Text( + stringResource(R.string.rust_backpack_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + Backpacks(state, viewModel) + } + } +} + +/** + * Your own backpack, one linked account on one server at a time, read when you + * press Open and not again until you press it again (§11.3.2). No icons (D340): + * each slot is the game's own name for the item. + */ +@Composable +private fun Backpacks(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) { + val links = (state.links as? UiState.Success)?.data.orEmpty() + val servers = (state.backpackServers as? UiState.Success)?.data.orEmpty() + + if (links.isEmpty() || servers.isEmpty()) { + Text( + stringResource(R.string.rust_backpack_unlinked), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + servers.forEach { server -> + ShardCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.titleSmall) + + links.forEach { link -> + val key = RustAccountViewModel.BackpackKey(server.id, link.steamId) + val open = state.backpackOpen == key + + Row(verticalAlignment = Alignment.CenterVertically) { + if (links.size > 1) { + Text( + playerLabel(link.name, link.steamId), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + TextButton(onClick = { viewModel.openBackpack(server.id, link.steamId) }) { + Text(stringResource(if (open) R.string.rust_backpack_read_again else R.string.rust_backpack_open)) + } + if (open) { + TextButton(onClick = viewModel::closeBackpack) { + Text(stringResource(R.string.rust_backpack_close)) + } + } + } + + if (open) { + when (val read = state.backpack) { + null, is UiState.Loading -> LoadingView() + is UiState.Error -> BackpackRefusal(read) { viewModel.openBackpack(server.id, link.steamId) } + is UiState.Success -> BackpackGrid(read.data) + } + } + } + } + } + } +} + +/** 409 and 404 are sentences, not retries: Backpacks is missing, or the site cannot show it yet. */ +@Composable +private fun BackpackRefusal(error: UiState.Error, onRetry: () -> Unit) { + val sentence = when (error.httpStatus) { + 409 -> R.string.rust_backpack_missing + 404 -> R.string.rust_backpack_unavailable + else -> null + } + if (sentence == null) { + ErrorView(error.kind, onRetry = onRetry) + return + } + Text( + stringResource(sentence), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) +} + +/** Pages of four-wide rows: a phone is narrow, and a slot needs room for an item's name. */ +@Composable +private fun BackpackGrid(backpack: RustBackpackDto) { + if (!backpack.exists) { + Text( + stringResource(R.string.rust_backpack_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + return + } + + val pages = backpackPages(backpack) + val waiting = backpack.items.count { it.overflow } + + Text( + stringResource(R.string.rust_backpack_summary, backpack.items.size, backpack.capacity), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + if (waiting > 0) { + Text( + stringResource(R.string.rust_backpack_overflow, waiting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + pages.forEach { page -> + if (pages.size > 1) { + Text( + stringResource(R.string.rust_backpack_page, page.index + 1), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + page.cells.chunked(BACKPACK_COLUMNS).forEach { row -> + Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + row.forEach { cell -> BackpackSlot(cell, Modifier.weight(1f)) } + repeat(BACKPACK_COLUMNS - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +private const val BACKPACK_COLUMNS = 4 + +@Composable +private fun BackpackSlot(cell: BackpackCell, modifier: Modifier) { + val stack = cell.stack + val outline = if (cell.overflow) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outlineVariant + Column( + modifier + .heightIn(min = 56.dp) + .border(1.dp, outline, RoundedCornerShape(4.dp)) + .padding(4.dp), + ) { + if (stack != null) { + Text(stackLabel(stack), style = MaterialTheme.typography.labelSmall, maxLines = 3, overflow = TextOverflow.Ellipsis) + stack.contents.forEach { inner -> + Text("+ ${stackLabel(inner)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (stack.amount > 1) { + Text("×${stack.amount}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** + * One card per server: each linked account's balance where the server runs + * Economics, and the reason where it does not (D199) — a player asking where + * their coins are is owed the answer, not an empty space. + */ +@Composable +private fun Coins(servers: List) { + if (servers.none { it.accounts.isNotEmpty() }) { + Text( + stringResource(R.string.rust_coins_unlinked), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + servers.forEach { server -> + ShardCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(server.server.ifBlank { server.serverId }, style = MaterialTheme.typography.titleSmall) + + if (!server.economics) { + Text( + stringResource(R.string.rust_coins_missing), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } else { + server.accounts.forEach { account -> + Text( + stringResource(R.string.rust_coins_balance, playerLabel(account.name, account.steamId), coinAmount(account.balance)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + ) + if (account.source == "last-known") { + Text( + stringResource(R.string.rust_coins_last_known), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +/** + * The code form. + * + * The three-step instruction is not decoration: nothing else in the app tells a + * player that the code comes from the game, and a code field with no explanation + * is a code field nobody can use. + */ +@Composable +private fun LinkCard(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) { + var code by rememberSaveable { mutableStateOf("") } + + ShardCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(stringResource(R.string.rust_link_title), style = MaterialTheme.typography.titleMedium) + + Text( + stringResource(R.string.rust_link_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + + OutlinedTextField( + value = code, + onValueChange = { code = it.uppercase() }, + singleLine = true, + enabled = !state.busy, + label = { Text(stringResource(R.string.rust_link_code)) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + + Button( + onClick = { viewModel.link(code); code = "" }, + enabled = !state.busy && code.isNotBlank(), + modifier = Modifier.padding(top = 12.dp), + ) { Text(stringResource(R.string.rust_link_action)) } + + // Beside the button that caused it. A refusal at the top of a long + // scroll is a press that visibly did nothing (PLAN.md §21.5). + state.feedback?.let { feedback -> + Text( + text = stringResource(feedback.messageRes), + style = MaterialTheme.typography.bodySmall, + color = if (feedback.ok) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + modifier = Modifier.padding(top = 10.dp), + ) + } + } + } +} + +/** One linked Steam account, and the button that releases it. */ +@Composable +private fun LinkRow(link: RustLinkDto, busy: Boolean, onUnlink: () -> Unit) { + ShardCard(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = link.name?.takeIf { it.isNotBlank() } ?: link.steamId, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = link.steamId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // When, and which server minted the code. Not part of the + // identity — a link is fleet-wide — but it is where a support + // conversation starts, and the website's own row says it. + val when_ = rustAgo(link.linkedAt) + val where = link.serverId?.takeIf { it.isNotBlank() } + + if (when_ != null || where != null) { + Text( + text = listOfNotNull( + when_?.let { stringResource(R.string.rust_account_linked_when, it) }, + where, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + TextButton(onClick = onUnlink, enabled = !busy) { + Text(stringResource(R.string.rust_unlink_action)) + } + } + } +} + +/** Ranks and grants, drawn the same way because they read the same. */ +@Composable +private fun Held(held: RustPlayerPermissionsDto) { + if (held.groups.isEmpty() && held.grants.isEmpty()) { + Text( + stringResource(R.string.rust_held_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + held.groups.forEach { group -> + HeldCard( + title = group.title.ifBlank { group.name }, + detail = group.permissions.joinToString(" · ").takeIf { it.isNotBlank() }, + reach = group.reach, + ) + } + + held.grants.forEach { grant -> + HeldCard( + title = grant.permission, + detail = grant.note?.takeIf { it.isNotBlank() }, + reach = grant.reach, + ) + } + + // Zero linked accounts is WHY everything above is waiting, and the screen + // says so rather than leaving a page of hollow pills to be read as a fault. + if (held.accounts == 0) { + Text( + stringResource(R.string.rust_held_unlinked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (held.groups.any { it.reach.any { s -> !s.live } } || + held.grants.any { it.reach.any { s -> !s.live } } + ) { + Text( + stringResource(R.string.rust_held_waiting_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun HeldCard(title: String, detail: String?, reach: List) { + ShardCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + if (reach.isEmpty()) { + Text( + stringResource(R.string.rust_held_no_servers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 10.dp), + ) { + reach.forEach { server -> + // The tone IS the state: a server that has it and one + // that has not are the two things this screen exists to + // tell apart, and colour alone would not say which — so + // the label carries the word as well. + StatusPill( + text = stringResource( + if (server.live) R.string.rust_reach_live else R.string.rust_reach_waiting, + server.name.ifBlank { server.id }, + ), + tone = if (server.live) PillTone.Success else PillTone.Neutral, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt new file mode 100644 index 0000000..eeec11d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustAccountViewModel.kt @@ -0,0 +1,210 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.R +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustEconomicsServerDto +import com.runicgateway.app.data.api.dto.RustLinkDto +import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.repository.PlayerRustRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The player's own Rust account (PLAN.md §9 M15; `docs/modules/rust/PLAN.md` + * §19, §20, §22): link a Steam account with the code `/link` hands out in game, + * release one, and see what the site has given them on which servers. + * + * **Two reads, one screen, and neither blocks the other.** The entitlement read + * carries its own state because an entitlement is authored against the *website* + * account: it exists before a Steam id does, and the person who has just been + * given something and has not linked yet is exactly the one who needs to see + * both halves at once. A failure on either side leaves the other on screen. + * + * **A refusal is chosen by status, not by prose** (the convention + * [com.runicgateway.app.ui.player.CharactersViewModel] set one game along). The + * four the website distinguishes are four different pieces of advice, and + * flattening them is the failure worth naming: a player told to get a new code + * when the server their code came from was merely unreachable will go and get + * another code from the same unreachable server. + */ +@HiltViewModel +class RustAccountViewModel @Inject constructor( + private val repository: PlayerRustRepository, +) : ViewModel() { + + /** A one-shot banner for the code form, rendered beside the button that caused it. */ + data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int) + + /** Which backpack: one linked account on one server. */ + data class BackpackKey(val serverId: String, val steamId: String) + + data class State( + val links: UiState> = UiState.Loading, + val held: UiState = UiState.Loading, + /** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */ + val coins: UiState> = UiState.Loading, + /** The servers the backpack section offers (PLAN_REDESIGNS §11.3.2). */ + val backpackServers: UiState> = UiState.Loading, + /** The one backpack open, by server and account, or null when none is. */ + val backpackOpen: BackpackKey? = null, + /** That backpack, read live when opened; null before the first open. */ + val backpack: UiState? = null, + val busy: Boolean = false, + val feedback: Feedback? = null, + /** The Steam id currently being released, so only its own row shows it. */ + val unlinking: String? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.update { it.copy(links = UiState.Loading, held = UiState.Loading, coins = UiState.Loading) } + + viewModelScope.launch { + _state.update { it.copy(links = repository.links().toUiState()) } + } + + viewModelScope.launch { + _state.update { it.copy(held = repository.permissions().toUiState()) } + } + + reloadCoins() + + viewModelScope.launch { + _state.update { it.copy(backpackServers = repository.servers().toUiState()) } + } + } + + /** + * Open one backpack (PLAN_REDESIGNS §11.3.2): **one read per press, and + * nothing polls**, because a read runs on the game's main thread. Pressing + * the open one again reads it again; opening another closes the first. + */ + fun openBackpack(serverId: String, steamId: String) { + val key = BackpackKey(serverId, steamId) + if (_state.value.backpackOpen == key && _state.value.backpack is UiState.Loading) return + _state.update { it.copy(backpackOpen = key, backpack = UiState.Loading) } + + viewModelScope.launch { + val read = repository.backpack(serverId, steamId).toUiState() + // A slow answer for a backpack the player has since closed or swapped is dropped. + _state.update { if (it.backpackOpen == key) it.copy(backpack = read) else it } + } + } + + fun closeBackpack() = _state.update { it.copy(backpackOpen = null, backpack = null) } + + /** The balances follow the links: a new link is a new account to read. */ + private fun reloadCoins() { + viewModelScope.launch { + _state.update { it.copy(coins = repository.economics().toUiState()) } + } + } + + /** Re-read what the site holds without blanking the accounts above it. */ + private fun reloadHeld() { + viewModelScope.launch { + _state.update { it.copy(held = repository.permissions().toUiState()) } + } + } + + fun clearFeedback() = _state.update { it.copy(feedback = null) } + + fun link(code: String) { + if (_state.value.busy || code.isBlank()) return + _state.update { it.copy(busy = true, feedback = null) } + + viewModelScope.launch { + when (val result = repository.link(code.trim())) { + is ApiResult.Ok -> { + // `already` is a second press of the button, not an error: + // the code was good and that account was already theirs. + val res = + if (result.data.already) R.string.rust_link_already else R.string.rust_link_ok + + _state.update { it.copy(busy = false, feedback = Feedback(true, res)) } + refreshAfterChange() + } + + is ApiResult.HttpError -> _state.update { + it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status))) + } + + is ApiResult.NetworkError -> _state.update { + it.copy(busy = false, feedback = Feedback(false, R.string.error_network)) + } + } + } + } + + fun unlink(steamId: String) { + if (_state.value.unlinking != null) return + _state.update { it.copy(unlinking = steamId, feedback = null) } + + viewModelScope.launch { + val result = repository.unlink(steamId) + _state.update { it.copy(unlinking = null) } + + when (result) { + is ApiResult.Ok -> refreshAfterChange() + + is ApiResult.HttpError -> _state.update { + it.copy(feedback = Feedback(false, R.string.rust_unlink_error)) + } + + is ApiResult.NetworkError -> _state.update { + it.copy(feedback = Feedback(false, R.string.error_network)) + } + } + } + } + + /** + * Both halves, after the caller changed one of them. + * + * Linking an account does not change what the site has authored — but it + * changes what reaches a game, and the next sync is what makes that true. So + * the entitlement list is re-read too: its `live` marks are the only thing on + * this screen that a link can silently invalidate. + */ + private fun refreshAfterChange() { + viewModelScope.launch { + _state.update { it.copy(links = repository.links().toUiState()) } + } + reloadHeld() + reloadCoins() + } + + private fun linkErrorRes(status: Int): Int = when (status) { + // Unknown or expired: the code is spent, and the way out is a new one. + 400 -> R.string.rust_link_bad_code + // Another website account holds that Steam id. It is never moved + // silently; `/unlink` in game is the release (D23). + 409 -> R.string.rust_link_taken + 429 -> R.string.rust_link_capped + // A server could not be reached. **The code is still good**, which is + // why this may not say "get a new one". + 503 -> R.string.rust_link_unreachable + else -> R.string.rust_link_error + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustBadgeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustBadgeViewModel.kt new file mode 100644 index 0000000..0750f0c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustBadgeViewModel.kt @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.repository.RustRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The live player count on the drawer's Rust row — the phone's answer to D15 + * (`docs/modules/rust/PLAN.md` §17.4). + * + * ## Why the drawer and not a footer + * + * D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which + * exists because every page of the website renders the same footer. The app has + * no footer and no slot; what it has is a drawer row per surface and, already, a + * precedent for a number beside one — the inbox's unread badge, in the same + * `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So + * the count rides there. + * + * **The number is players, not servers.** A badge is one integer, and of the two + * halves of D15's line the live one is how many people are on: a server count + * changes when an operator edits configuration, which is not news, and is visible + * on the page the row opens anyway. + * + * ## What keeps it honest + * + * The website's version renders nothing until it has an answer, nothing at all if + * the request fails, and never polls — because one request per page view is a + * cost and a timer in a footer on every page is a different kind of thing. All + * three rules hold here: + * + * - **Zero renders nothing.** No badge, rather than a `0` — an empty server is + * not a notification. + * - **A failure leaves the last count** rather than dropping to zero. A moment + * with no connectivity is not everybody logging off. + * - **It refreshes on resume, with the unread badge**, and never on a timer. The + * count is a glance, not a feed. + * + * It is asked for **only when the module is installed** — the caller gates on the + * `rust` capability — so a site running a different game makes no request at all. + */ +@HiltViewModel +class RustBadgeViewModel @Inject constructor( + private val repository: RustRepository, +) : ViewModel() { + + private val _online = MutableStateFlow(0) + + /** How many people are on across every server, or 0 when there is nothing to say. */ + val online: StateFlow = _online.asStateFlow() + + /** + * Ask, if the Rust module is there. + * + * [installed] is passed in rather than read here so this holds no opinion + * about capabilities: the drawer already knows, and a view model that + * re-derived it would be a second copy of a rule that lives in one place. + * Absent — the host has not answered yet — makes no request and keeps + * whatever is showing. + */ + fun refresh(installed: Boolean) { + if (!installed) { + _online.value = 0 + return + } + + viewModelScope.launch { + when (val result = repository.servers()) { + // A server that is stale or unreachable already answers `online: + // false` with `players: 0`, so summing the whole list needs no + // second staleness rule here. + is ApiResult.Ok -> _online.value = result.data.sumOf { it.players } + // Keep the last number. See the class doc. + else -> Unit + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt new file mode 100644 index 0000000..354c093 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt @@ -0,0 +1,199 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustEventDto + +/** + * One stored frame as one line of a feed — the Kotlin half of `module-rust`'s + * `client/src/lib/feed.js` (PLAN.md §9 M14). + * + * `GET /public/rust/servers/{id}/events` answers rows shaped + * `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the + * bridge plugin emitted. Everything a killfeed line needs is in there, under the + * names the plugin wrote, and **this file is the one place in the app that knows + * them**. + * + * ## It returns parts, not a sentence + * + * A row wants the names emphasised and the detail muted, and a function returning + * `"Alice killed Bob"` would force the screen to re-parse its own output to style + * it. Parts also make this testable without Compose, which is the only way this + * leg has real coverage of what a feed row says. + * + * ## The rule for an unknown kind + * + * **It renders as itself.** A later protocol adds kinds and an operator's module + * may be older than their game host, so a feed that dropped what it did not + * recognise would be a screen quietly saying less than the truth. The server's + * allowlist has already decided the row may be seen; what is left here is + * presentation, and the honest presentation of a kind we have no words for is its + * own name. + */ + +/** The row's category, for the small colour a screen gives it — never for meaning. */ +enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER } + +/** + * One row, ready to render. + * + * [actor] and [subject] are names and are emphasised; [verb] and [detail] are + * prose. Any of them may be null or empty. + * + * [join] is what goes between the actor and the verb, and it exists for exactly + * one case: chat. "Brannock see you in september" is not a sentence anybody + * writes, and putting the colon in the message would put presentation inside text + * a player typed. + */ +data class FeedLine( + val tone: FeedTone, + val actor: String? = null, + val join: String = " ", + val verb: String = "", + val subject: String? = null, + val detail: String = "", +) + +/** One filter the feed offers, and the kinds it asks the API for. */ +data class FeedFilter(val id: String, val label: String, val kinds: List) + +/** + * Kinds this feed asks for. + * + * `player.tally` is public and deliberately **not** here: it is an aggregate the + * plugin flushes every sixty seconds per active player, so a feed including it + * would be mostly wood counts. It is the leaderboard's input, and the leaderboard + * is where it shows up. + */ +val FEED_KINDS: List = listOf( + "player.death", + "player.connected", + "player.disconnected", + "player.respawned", + "player.chat", + "server.wipe", + "server.initialized", + "server.shutdown", +) + +/** The filters the feed offers. The first is the default and asks for everything. */ +val FEED_FILTERS: List = listOf( + FeedFilter("all", "Everything", FEED_KINDS), + FeedFilter("kills", "Kills", listOf("player.death")), + FeedFilter("chat", "Chat", listOf("player.chat")), + FeedFilter( + "sessions", + "Comings and goings", + listOf("player.connected", "player.disconnected", "player.respawned"), + ), + FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")), +) + +/** The kinds a filter id asks for; an id nobody offers falls back to everything. */ +fun kindsFor(filterId: String): List = + (FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds + +/** One row as the parts a screen renders. */ +fun describe(row: RustEventDto): FeedLine { + val name = row.str("name") + + return when (row.kind) { + "player.death" -> death(row, name) + + "player.connected" -> + FeedLine(FeedTone.JOIN, actor = name, verb = "connected") + + "player.disconnected" -> FeedLine( + tone = FeedTone.LEAVE, + actor = name, + verb = "disconnected", + // Two optional halves, and the session is the interesting one. The + // plugin OMITS `sessionSec` for a player who was already on when it + // loaded, so an absent value means "unknown" and never zero — which is + // why this reads the parsed number rather than trusting a default. + detail = listOfNotNull( + row.str("reason"), + row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" }, + ).joinToString(" · "), + ) + + "player.respawned" -> + FeedLine(FeedTone.JOIN, actor = name, verb = "respawned") + + "player.chat" -> FeedLine( + tone = FeedTone.CHAT, + actor = name, + join = ": ", + // The message is the row, so it goes in `verb` where a screen renders + // it unemphasised — and it is the one field on this wire whose bytes a + // player chooses. Compose renders it as text and never as markup; + // nothing here may ever stop doing that. + verb = row.str("message").orEmpty(), + detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(), + ) + + "server.wipe" -> FeedLine( + tone = FeedTone.SERVER, + verb = "The map was wiped", + detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(), + ) + + "server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up") + + "server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down") + + else -> FeedLine( + tone = FeedTone.OTHER, + actor = name, + verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown", + ) + } +} + +/** + * A death, which is four different sentences. + * + * The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so + * a reader does not have to guess from an absent field, and collapsing any two of + * them loses something. A killfeed reporting a fall as a kill by nobody is the + * failure this avoids. + */ +private fun death(row: RustEventDto, name: String?): FeedLine { + val where = listOfNotNull( + row.str("weapon")?.let { "with ${weaponName(it, row.str("weaponName"))}" }, + row.num("distance")?.let { "${Math.round(it)}m" }, + row.str("grid"), + if (row.flag("sleeping")) "while sleeping" else null, + ).joinToString(" · ") + + return when (row.str("attackerType")) { + "player" -> FeedLine( + tone = FeedTone.KILL, + actor = row.str("attackerName"), + verb = "killed", + subject = name, + detail = where, + ) + + "self" -> FeedLine( + tone = FeedTone.DEATH, + actor = name, + verb = "died by their own hand", + detail = where, + ) + + "npc" -> FeedLine( + tone = FeedTone.DEATH, + actor = prefabName(row.str("attackerName")).ifBlank { "Something" }, + verb = "killed", + subject = name, + detail = where, + ) + + // `environment` and anything else: falling, drowning, the world. The + // plugin legitimately has no attacker on this path, so an ABSENT type is + // this case rather than a missing field to complain about. + else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt new file mode 100644 index 0000000..8afe24f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -0,0 +1,233 @@ +/* + * 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)) +} + +/** + * The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in + * the PHONE's zone — or null when there is no instant to show. + * + * The module computes the instant from the operator's rule in the operator's + * zone; the phone only says it the reader's way, which is the same split the + * website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is + * the caller's, from a string resource. + */ +fun nextWipeWhen( + value: String?, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String? { + val at = parseWireInstant(value) ?: return null + val local = at.atZone(zone) + val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local) + val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local) + val distance = rustAgo(value, now) ?: return "$date, $time" + return "$date, $time · $distance" +} + +/** + * "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() +} + +/** The endings a held entity's prefab name carries that no player says. */ +private val WEAPON_ENDING = Regex("[._](entity|weapon|deployed)$") + +/** + * The weapon in a kill, the Kotlin half of `module-rust`'s `format.js weapon()`. + * The module sends `weaponName` (Module-Rust#60), which is the game's own name for a + * gun or the prefab name as words (`server/model/titles/weaponNames.js`), and the + * app prints that. A module too old to send it gets the same fallback here: the + * `.entity` / `.weapon` / `.deployed` ending is dropped, then [prefabName] turns the + * rest into words. So the app never prints "ak47u entity". + */ +fun weaponName(weapon: String?, weaponName: String?): String { + if (weapon.isNullOrBlank()) return "" + if (!weaponName.isNullOrBlank()) return weaponName + return prefabName(weapon.trim().replace(WEAPON_ENDING, "")) +} + +/** + * An Economics balance (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): grouped, + * at most two places, and a dash for one the server never reported — not "0", + * which is a claim about somebody's balance that nobody made. + */ +fun coinAmount(value: Double?, locale: java.util.Locale = java.util.Locale.getDefault()): String { + val n = value ?: return "—" + if (!n.isFinite()) return "—" + return java.text.NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 2 }.format(n) +} + +/** 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) + +/** + * A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else — + * a title with a colour this build cannot read is drawn in the theme's own chip + * colours rather than guessed at (M19). + */ +fun titleArgb(hex: String?): Long? { + val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null + return 0xFF000000L or m.groupValues[1].toLong(16) +} + +/** + * Whether a title chip of this colour needs DARK ink — the same rule as the + * website's `contrastInk`: black or white, whichever has the higher WCAG + * contrast. The colour is the operator's, chosen for a dark game chat, and the + * app draws in the reader's theme, so the colour becomes the chip and the ink is + * picked for it. + */ +fun titleInkIsDark(argb: Long): Boolean { + fun linear(channel: Long): Double { + val v = channel / 255.0 + return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) + } + val l = 0.2126 * linear((argb shr 16) and 0xFF) + + 0.7152 * linear((argb shr 8) and 0xFF) + + 0.0722 * linear(argb and 0xFF) + return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt new file mode 100644 index 0000000..0b90702 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt @@ -0,0 +1,698 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.imageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import coil.size.Size +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLayerDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.ui.LocalAssetResolver +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.ShardCard +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.math.roundToInt + +/** + * The Map tab (`docs/modules/rust/PLAN.md` §31, D121–D124). + * + * **The phone draws the map itself** (D121): Coil fetches the picture once per + * map into its disk cache, since the URL carries the hash, and a [Canvas] draws + * the picture, the grid and the markers, with pinch, pan and double tap. + * + * **Nothing here decides who may see what.** The server sends only the layers + * this viewer may see. The switches on the legend are the reader's convenience + * and never a boundary, and a layer the viewer was not sent is still listed, + * disabled, with who can see it: "staff only" explains an empty map where + * silence would imply an empty server (§23.3's shape). + */ +@Composable +fun RustMapPanel( + serverOnline: Boolean, + onOpenEvent: (slug: String, runId: String) -> Unit, + viewModel: RustMapViewModel = hiltViewModel(), +) { + val ui by viewModel.state.collectAsStateWithLifecycle() + + when (val s = ui.map) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load) + is UiState.Success -> { + val map = s.data + val geometry = map.geometry + if (geometry == null) { + EmptyView(stringResource(R.string.rust_map_none)) + return + } + + // D124: while RESUMED and on this tab, which is exactly while this + // composable is on screen. Nothing is asked for a viewer who is sent + // nothing that moves. + if (map.anyLive) { + val interval = map.pollMs?.takeIf { it > 0 } ?: DEFAULT_POLL_MS + key(interval) { PollWhileResumed(intervalMs = interval) { viewModel.poll() } } + } + + MapContent( + map = map, + frame = remember(geometry) { MapFrame(geometry) }, + ui = ui, + serverOnline = serverOnline, + onToggle = viewModel::toggle, + onSelect = viewModel::select, + onOpenEvent = onOpenEvent, + ) + } + } +} + +private const val DEFAULT_POLL_MS = 10_000L + +/** What became of the picture. */ +private sealed interface Picture { + data object None : Picture + data object Loading : Picture + data object Failed : Picture + data class Ready(val bitmap: ImageBitmap) : Picture +} + +@Composable +private fun MapContent( + map: RustMapDto, + frame: MapFrame, + ui: RustMapUi, + serverOnline: Boolean, + onToggle: (MapLayer) -> Unit, + onSelect: (MapMarker?) -> Unit, + onOpenEvent: (String, String) -> Unit, +) { + val picture = rememberPicture(map.picture?.path) + val markers = remember(map, ui.live, ui.shown) { mapMarkers(map, ui.live, ui.shown) } + + Column(Modifier.fillMaxSize()) { + when (picture) { + Picture.None -> Note(stringResource(R.string.rust_map_no_picture)) + Picture.Failed -> Note(stringResource(R.string.rust_map_picture_failed)) + else -> Unit + } + + Box(Modifier.fillMaxWidth().weight(1f)) { + MapCanvas( + frame = frame, + background = parseColour(frame.geometry.background) ?: OCEAN, + picture = (picture as? Picture.Ready)?.bitmap, + showGrid = MapLayer.GRID in ui.shown, + markers = markers, + selected = ui.selected, + // A new map is a new frame; the reader's zoom on the old one means nothing. + resetKey = map.mapKey, + onSelect = onSelect, + ) + ui.selected?.let { marker -> + MarkerCard( + marker = marker, + grid = marker.grid ?: frame.gridLabel(marker.x, marker.z), + eventSlug = marker.runId?.let { ui.eventSlugs[it] }, + eventTitle = marker.runId?.let { ui.eventTitles[it] }, + onOpenEvent = onOpenEvent, + onClose = { onSelect(null) }, + modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp), + ) + } + } + + Note(liveStatus(map, ui, serverOnline)) + + Column( + Modifier + .fillMaxWidth() + .heightIn(max = LEGEND_MAX_HEIGHT) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Legend(map, ui.live, ui.shown, onToggle) + } + } +} + +private val LEGEND_MAX_HEIGHT = 240.dp + +/** + * The picture, decoded once at its own size as `RGB_565` with hardware bitmaps + * off: half the memory of ARGB, 12.5 MB at the rig's 2 500 px (§31.2). + */ +@Composable +private fun rememberPicture(path: String?): Picture { + val context = LocalContext.current + val resolve = LocalAssetResolver.current + val url = path?.takeIf { it.isNotBlank() }?.let { resolve(mapPictureUrl(it)) } + + // Keyed on the URL, which carries the picture's hash: a new map is a new + // load, and the old picture stays drawn until the new one has arrived. + var picture by remember { mutableStateOf(if (url == null) Picture.None else Picture.Loading) } + LaunchedEffect(url) { + picture = if (url == null) Picture.None else loadPicture(context, url) + } + return picture +} + +private suspend fun loadPicture(context: Context, url: String): Picture { + val request = ImageRequest.Builder(context) + .data(url) + .size(Size.ORIGINAL) + .bitmapConfig(Bitmap.Config.RGB_565) + .allowHardware(false) + .build() + val result = context.imageLoader.execute(request) as? SuccessResult + val bitmap = (result?.drawable as? BitmapDrawable)?.bitmap ?: return Picture.Failed + return Picture.Ready(bitmap.asImageBitmap()) +} + +@Composable +private fun MapCanvas( + frame: MapFrame, + background: Color, + picture: ImageBitmap?, + showGrid: Boolean, + markers: List, + selected: MapMarker?, + resetKey: String?, + onSelect: (MapMarker?) -> Unit, +) { + val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat()) + var view by remember { mutableStateOf(null) } + // Null until the reader moves the map: until then, and after a new map + // resets it, the map is drawn fitted to the view. **Saveable**, because + // Open event leaves this screen and Back returns to it: the walk found the + // reader's zoom gone on the way back, the card still up over a whole-world + // view of the marker it described. + var moved by rememberSaveable(resetKey, pictureSize, stateSaver = TransformSaver) { + mutableStateOf(null) + } + val transform = moved ?: view?.let { MapTransform.fit(it, pictureSize) } + + // Read through updated state so the gesture handlers, which are installed + // once, act on what is drawn now rather than on what was drawn when they were. + val currentMarkers by rememberUpdatedState(markers) + val currentTransform by rememberUpdatedState(transform) + val currentFrame by rememberUpdatedState(frame) + + val textMeasurer = rememberTextMeasurer() + val labelStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = GRID_LABEL) + val grid = remember(frame) { frame.grid() } + val labels: List> = remember(grid, textMeasurer) { + grid.labels.map { it to textMeasurer.measure(it.text, labelStyle) } + } + val description = stringResource(R.string.rust_map_description) + + Canvas( + Modifier + .fillMaxSize() + .clipToBounds() + .background(SURROUND) + .semantics { contentDescription = description } + .onSizeChanged { + val next = ViewSize(it.width.toFloat(), it.height.toFloat()) + // A new size keeps the reader's view rather than refitting: the + // box grows and shrinks with the status line under it, and a + // rotation is still the same place. The first size after a + // return is not a new one, and keeps the restored zoom. + val previous = view + if (next != previous) { + val m = moved + if (previous != null && m != null) moved = m.resized(previous, next, pictureSize) + view = next + } + } + .pointerInput(pictureSize) { + detectTransformGestures { centroid, pan, zoom, _ -> + val v = view ?: return@detectTransformGestures + val t = currentTransform ?: return@detectTransformGestures + moved = t.transformed(v, pictureSize, centroid.x, centroid.y, pan.x, pan.y, zoom) + } + } + .pointerInput(pictureSize) { + detectTapGestures( + onTap = { at -> + val t = currentTransform ?: return@detectTapGestures + onSelect(nearestMarker(currentMarkers, currentFrame, t, at.x, at.y, TAP_REACH.toPx())) + }, + onDoubleTap = { at -> + val v = view ?: return@detectTapGestures + val t = currentTransform ?: return@detectTapGestures + moved = t.transformed(v, pictureSize, at.x, at.y, 0f, 0f, 2f) + }, + ) + }, + ) { + val t = transform ?: return@Canvas + val left = t.offsetX + val top = t.offsetY + val w = pictureSize.width * t.scale + val h = pictureSize.height * t.scale + + // Without a picture the geometry is filled with the game's own colour. + drawRect(background, topLeft = Offset(left, top), size = androidx.compose.ui.geometry.Size(w, h)) + picture?.let { + drawImage( + image = it, + dstOffset = IntOffset(left.roundToInt(), top.roundToInt()), + dstSize = IntSize(w.roundToInt(), h.roundToInt()), + filterQuality = FilterQuality.Low, + ) + } + + if (showGrid) drawGrid(frame, t, grid, labels) + markers.forEach { drawMarker(frame, t, it, highlighted = it == selected) } + } +} + +private fun DrawScope.drawGrid( + frame: MapFrame, + t: MapTransform, + grid: MapGrid, + labels: List>, +) { + val stroke = 1.dp.toPx() + fun at(x: Double, z: Double): Offset { + val p = frame.toPixel(x, z) + return Offset(t.screenX(p.x), t.screenY(p.y)) + } + grid.lines.forEach { drawLine(GRID_LINE, at(it.x1, it.z1), at(it.x2, it.z2), strokeWidth = stroke) } + + // Labels only once a cell is wide enough on screen to hold one: at the + // fitted zoom a 20-cell map's labels overlap into a wall of text (found on + // the phase 14 walk, and the web's rule). + val cellPx = frame.metres(frame.geometry.gridCellSize) * t.scale + if (cellPx < LABEL_MIN_CELL.toPx()) return + val pad = Offset(3.dp.toPx(), 2.dp.toPx()) + labels.forEach { (label, layout) -> + val corner = at(label.x, label.z) + // Only the ones on screen: a zoomed-in 20-cell map has 400 and shows a few. + if (corner.x > size.width || corner.y > size.height) return@forEach + if (corner.x + cellPx < 0 || corner.y + cellPx < 0) return@forEach + drawText(layout, topLeft = corner + pad) + } +} + +/** + * One marker. **Points are drawn at a fixed size on screen** whatever the zoom, so + * a dot does not grow over the monument it sits on at 4×; **a zone is drawn on + * the ground**, in metres, because how far it reaches is what it says (§31.4). + */ +private fun DrawScope.drawMarker(frame: MapFrame, t: MapTransform, m: MapMarker, highlighted: Boolean) { + val p = frame.toPixel(m.x, m.z) + val centre = Offset(t.screenX(p.x), t.screenY(p.y)) + + if (m.isZone) { + val radius = (frame.metres(m.radiusMetres ?: 0.0) * t.scale).toFloat() + drawCircle(EVENT.copy(alpha = 0.12f), radius, centre) + drawCircle(EVENT, radius, centre, style = Stroke(width = (if (highlighted) 3 else 2).dp.toPx())) + return + } + + val style = markerStyle(m) + val radius = style.radius.dp.toPx() + drawCircle(style.colour.copy(alpha = 0.95f), radius, centre) + drawCircle(Color.Black, radius, centre, style = Stroke(width = style.outline.dp.toPx())) + if (highlighted) drawCircle(Color.White, radius + 4.dp.toPx(), centre, style = Stroke(width = 2.dp.toPx())) +} + +/** The zoom and pan as three floats, so they survive leaving the screen. */ +private val TransformSaver = Saver( + save = { t -> t?.let { floatArrayOf(it.scale, it.offsetX, it.offsetY) } }, + restore = { MapTransform(it[0], it[1], it[2]) }, +) + +private data class MarkerStyle(val colour: Color, val radius: Int, val outline: Int = 1) + +/** The web's colours and sizes (`MapView.jsx`), so the two maps read alike. */ +private fun markerStyle(m: MapMarker): MarkerStyle = when (m.layer) { + MapLayer.WORLD -> when (m.kind) { + "monument" -> MarkerStyle(MONUMENT, 4) + "cargo" -> MarkerStyle(WORLD_COLOURS.getValue("cargo"), 7) + // A RunicNPC boss outside any event (runicnpc D302): as big as a ship. + "boss" -> MarkerStyle(BOSS, 7, 2) + else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5) + } + // An event's boss is bigger (runicnpc D303). + MapLayer.EVENTS -> if (m.boss) MarkerStyle(BOSS, 8, 2) else MarkerStyle(EVENT, 5) + MapLayer.PLAYERS -> if (m.online) MarkerStyle(ONLINE, 5) else MarkerStyle(SLEEPING, 4) + MapLayer.BASES -> MarkerStyle(if (m.kind == "vending") VENDING else TC, 4) + MapLayer.MATES -> if (m.self) MarkerStyle(SELF, 8, 2) else MarkerStyle(MATE, 6, 2) + MapLayer.GRID -> MarkerStyle(Color.White, 0) +} + +/** + * What was tapped: what it is, its grid square, and what its layer carried. + * Nothing is looked up beyond what was sent (§31.4). + */ +@Composable +private fun MarkerCard( + marker: MapMarker, + grid: String?, + eventSlug: String?, + eventTitle: String?, + onOpenEvent: (String, String) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + ShardCard(modifier.fillMaxWidth()) { + Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) { + Text(markerTitle(marker), style = MaterialTheme.typography.titleSmall) + markerDetail(marker, eventTitle)?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + grid?.let { + Text( + stringResource(R.string.rust_map_grid, it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + // D123: a run core's public calendar lists opens the app's own event + // page on that run. A run it does not list (a rehearsal, an + // unlisted event) says only what it is. + val runId = marker.runId + if (marker.layer == MapLayer.EVENTS && runId != null && eventSlug != null) { + TextButton(onClick = { onOpenEvent(eventSlug, runId) }) { + Text(stringResource(R.string.rust_map_open_event)) + } + } + TextButton(onClick = onClose) { Text(stringResource(R.string.rust_map_close)) } + } + } + } +} + +@Composable +private fun markerTitle(m: MapMarker): String = when (m.layer) { + MapLayer.WORLD -> when (m.kind) { + "monument" -> m.name ?: m.kind + "cargo" -> stringResource(R.string.rust_map_cargo) + "heli" -> stringResource(R.string.rust_map_heli) + "chinook" -> stringResource(R.string.rust_map_chinook) + "bradley" -> stringResource(R.string.rust_map_bradley) + "supply" -> stringResource(R.string.rust_map_supply) + "crate" -> stringResource(R.string.rust_map_crate) + "boss" -> stringResource(R.string.rust_map_boss, m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_boss_unnamed)) + else -> prefabName(m.kind) + } + MapLayer.EVENTS -> when (m.kind) { + "zone" -> m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_event_zone) + // A RunicNPC NPC says its name, and whether it is a boss or a boss's add (D303). + "npc" -> { + val name = m.name?.takeIf { it.isNotBlank() } + when { + name == null -> stringResource(R.string.rust_map_event_npc) + m.boss -> stringResource(R.string.rust_map_boss, name) + m.add -> stringResource(R.string.rust_map_boss_add, name) + else -> name + } + } + else -> stringResource(R.string.rust_map_event_crate) + } + MapLayer.PLAYERS -> m.name ?: "" + MapLayer.BASES -> stringResource(if (m.kind == "vending") R.string.rust_map_vending else R.string.rust_map_tc) + MapLayer.MATES -> if (m.self) stringResource(R.string.rust_map_you) else m.name ?: stringResource(R.string.rust_map_clan_mate) + MapLayer.GRID -> "" +} + +@Composable +private fun markerDetail(m: MapMarker, eventTitle: String? = null): String? = when (m.layer) { + MapLayer.WORLD -> when { + m.kind != "crate" -> null + m.hacked -> stringResource(R.string.rust_map_crate_hacked) + m.hackLeftSec != null -> stringResource(R.string.rust_map_crate_hack, countdown(m.hackLeftSec)) + else -> null + } + // Every site-event marker says so, linked or not (§31.2): a run the public + // calendar does not list gets this line and no button. One it lists names its + // event (runicnpc D303). + MapLayer.EVENTS -> eventTitle?.let { stringResource(R.string.rust_map_site_event_named, it) } + ?: stringResource(R.string.rust_map_site_event) + MapLayer.PLAYERS, MapLayer.MATES -> when { + !m.online -> stringResource(R.string.rust_map_player_offline) + m.sleeping -> stringResource(R.string.rust_map_player_sleeping) + else -> null + } + else -> null +} + +/** + * One row for each layer. A layer the viewer was sent has a switch; one they + * were not is listed disabled with who can see it, and the players layer says + * when it is narrower than its own switch because presence is (D113). + */ +@Composable +private fun Legend(map: RustMapDto, live: RustMapLiveDto?, shown: Set, onToggle: (MapLayer) -> Unit) { + LegendRow(stringResource(R.string.rust_map_layer_grid), emptyList(), true, MapLayer.GRID in shown, null) { + onToggle(MapLayer.GRID) + } + LAYER_ROWS.forEach { (layer, labelRes) -> + val gate = map.layers.of(layer) + var note = if (gate.visible) null else hiddenNote(gate) + if (layer == MapLayer.PLAYERS && gate.cappedByPresence && !gate.visible) { + note = stringResource(R.string.rust_map_capped, note.orEmpty()) + } + if (gate.visible && layer == MapLayer.PLAYERS && live?.playersTruncated == true) { + note = stringResource(R.string.rust_map_players_truncated) + } + if (gate.visible && layer == MapLayer.BASES && live?.basesTruncated == true) { + note = stringResource(R.string.rust_map_bases_truncated) + } + LegendRow(stringResource(labelRes), SWATCHES.getValue(layer), gate.visible, gate.visible && layer in shown, note) { + onToggle(layer) + } + } + val mates = map.mates + if (mates.visible) { + LegendRow( + stringResource(R.string.rust_map_layer_mates), + SWATCHES.getValue(MapLayer.MATES), + true, + MapLayer.MATES in shown, + stringResource(R.string.rust_map_mates_note), + ) { onToggle(MapLayer.MATES) } + } else if (mates.on && mates.signedIn && !mates.linked) { + // Offered only to a signed-in, unlinked viewer on a server with the switch + // on, as the web does: anyone else could not act on it. + LegendRow( + stringResource(R.string.rust_map_layer_mates), + SWATCHES.getValue(MapLayer.MATES), + false, + false, + stringResource(R.string.rust_map_mates_link), + ) {} + } +} + +@Composable +private fun hiddenNote(gate: RustMapLayerDto): String = stringResource( + when (gate.audience) { + "signed_in" -> R.string.rust_map_hidden_signin + "public" -> R.string.rust_map_hidden_public + else -> R.string.rust_map_hidden_staff + }, +) + +@Composable +private fun LegendRow( + label: String, + swatches: List, + enabled: Boolean, + checked: Boolean, + note: String?, + onToggle: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch(checked = checked, onCheckedChange = { onToggle() }, enabled = enabled) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + swatches.forEach { + Spacer(Modifier.width(6.dp)) + Box(Modifier.size(10.dp).background(it, CircleShape)) + } + } + note?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun ColumnScope.Note(text: String) { + if (text.isBlank()) return + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) +} + +/** The line under the map, in the web's words (`liveStatus` in `MapView.jsx`). */ +@Composable +private fun liveStatus(map: RustMapDto, ui: RustMapUi, serverOnline: Boolean): String { + if (!map.anyLive) return stringResource(R.string.rust_map_status_nothing_live) + val live = ui.live + if (live == null) { + return stringResource(if (ui.liveFailed) R.string.rust_map_status_failed else R.string.rust_map_status_asking) + } + if (ui.liveFailed) return stringResource(R.string.rust_map_status_failed_kept) + if (!live.live) { + return stringResource(if (serverOnline) R.string.rust_map_status_not_now else R.string.rust_map_status_offline) + } + val at = ui.liveAt ?: return "" + return stringResource(R.string.rust_map_status_as_of, CLOCK.format(Instant.ofEpochMilli(at))) +} + +private val CLOCK: DateTimeFormatter = + DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault()) + +private fun com.runicgateway.app.data.api.dto.RustMapLayersDto.of(layer: MapLayer): RustMapLayerDto = when (layer) { + MapLayer.WORLD -> world + MapLayer.EVENTS -> events + MapLayer.PLAYERS -> players + MapLayer.BASES -> bases + else -> RustMapLayerDto() +} + +/** `#RRGGBB` as the plugin sends it, or null for anything else. */ +internal fun parseColour(value: String?): Color? { + val hex = value?.trim()?.removePrefix("#") ?: return null + if (hex.length != 6) return null + val rgb = hex.toLongOrNull(16) ?: return null + return Color(0xFF000000 or rgb) +} + +private val TAP_REACH = 24.dp +private val LABEL_MIN_CELL = 30.dp + +// The web's palette (`MapView.jsx` COLOURS), so a reader moving between the two +// reads the same marker as the same thing. +private val OCEAN = Color(0xFF0B3B4A) +private val SURROUND = Color(0xFF071F27) +private val GRID_LINE = Color.White.copy(alpha = 0.18f) +private val GRID_LABEL = Color.White.copy(alpha = 0.55f) +private val MONUMENT = Color(0xFFE8D9A8) +private val EVENT = Color(0xFFCE93D8) +private val BOSS = Color(0xFFD500F9) +private val ONLINE = Color(0xFFFFFFFF) +private val SLEEPING = Color(0xFF9E9E9E) +private val TC = Color(0xFFFF7043) +private val VENDING = Color(0xFF26A69A) +private val SELF = Color(0xFF00E5FF) +private val MATE = Color(0xFF7CFFB2) +private val WORLD_COLOURS = mapOf( + "cargo" to Color(0xFF4FC3F7), + "heli" to Color(0xFFEF5350), + "chinook" to Color(0xFFFFA726), + "bradley" to Color(0xFFA1887F), + "supply" to Color(0xFF66BB6A), + "crate" to Color(0xFFFFEE58), +) + +private val LAYER_ROWS = listOf( + MapLayer.WORLD to R.string.rust_map_layer_world, + MapLayer.EVENTS to R.string.rust_map_layer_events, + MapLayer.PLAYERS to R.string.rust_map_layer_players, + MapLayer.BASES to R.string.rust_map_layer_bases, +) + +private val SWATCHES = mapOf( + MapLayer.WORLD to listOf(MONUMENT, WORLD_COLOURS.getValue("cargo"), WORLD_COLOURS.getValue("heli"), WORLD_COLOURS.getValue("crate")), + MapLayer.EVENTS to listOf(EVENT), + MapLayer.PLAYERS to listOf(ONLINE, SLEEPING), + MapLayer.BASES to listOf(TC, VENDING), + MapLayer.MATES to listOf(SELF, MATE), +) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt new file mode 100644 index 0000000..248ca4c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt @@ -0,0 +1,190 @@ +/* + * 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.core.auth.Session +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.navigation.Routes +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.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Everything the Map tab is showing. */ +data class RustMapUi( + val map: UiState = UiState.Loading, + /** The last live answer, or null before the first. Kept when a later ask fails. */ + val live: RustMapLiveDto? = null, + /** True when the most recent ask failed; [live] is then the one before it. */ + val liveFailed: Boolean = false, + /** When [live] arrived, epoch ms. */ + val liveAt: Long? = null, + /** The legend's switches: the reader's convenience, never a boundary. */ + val shown: Set = MapLayer.entries.toSet(), + val selected: MapMarker? = null, + /** Run id → event slug, for the runs core's public calendar lists. */ + val eventSlugs: Map = emptyMap(), + /** Run id → event title, from the same read: an NPC's card names its event (runicnpc D303). */ + val eventTitles: Map = emptyMap(), +) + +/** + * One server's map (`docs/modules/rust/PLAN.md` §31, D121–D125; PLAN.md M17). + * + * ## It draws what it is sent + * + * `map` and `map/live` are projected per viewer **on the server**, from the same + * bearer token every other request carries. A layer this viewer may not see is + * absent from the answer, so nothing here decides who sees what. + * + * ## Keyed on the signed-in account + * + * Mates and the players layer are per viewer, and this view model lives as long + * as the server screen's back-stack entry, across a sign-out and a sign-in as + * somebody else. Events phase 14b found what that costs: a view model that loads + * once showed the next account the previous one's answer without asking. So the + * account is what this keys on. A change of account drops everything drawn, + * including the tapped card, and asks again, and an answer to a question asked + * for the previous account is thrown away when it lands (the [generation]). + * Signing out is a change too: the map is public, so a signed-out reader gets + * the public map rather than an empty one. + * + * ## When it asks + * + * `map` once when the tab opens, and again when a live answer names a different + * `mapKey`: a wipe or a new seed, whose picture follows without leaving the tab. + * `map/live` on the screen's poll (D124), and not at all when the server sends + * this viewer no moving layer. + */ +@HiltViewModel +class RustMapViewModel @Inject constructor( + private val repository: RustRepository, + events: EventsRepository, + sessionManager: SessionManager, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: "" + + private val resolver = EventRunResolver(events) + + private val _state = MutableStateFlow(RustMapUi()) + val state: StateFlow = _state.asStateFlow() + + /** Bumped on every change of account; an answer from an older one is dropped. */ + private var generation = 0 + + /** A poll still waiting for its answer: the next tick does not stack another. */ + private var polling = false + + init { + viewModelScope.launch { + sessionManager.state + .map { (it as? Session.SignedIn)?.user?.id } + .distinctUntilChanged() + .collect { + generation++ + polling = false + _state.update { s -> RustMapUi(shown = s.shown) } + load() + } + } + } + + /** The map's picture, frame and gates. A retry after a failure too. */ + fun load() { + val asked = generation + _state.update { it.copy(map = UiState.Loading) } + viewModelScope.launch { askMap(asked) } + } + + /** + * The poll tick. Asks only once the map has a frame to place things in and + * only when this viewer is sent something that moves. + */ + fun poll() { + val map = (_state.value.map as? UiState.Success)?.data ?: return + if (map.geometry == null || !map.anyLive || polling) return + + val asked = generation + polling = true + viewModelScope.launch { + val result = repository.mapLive(serverId) + if (asked != generation) return@launch + polling = false + + when (result) { + is ApiResult.Ok -> { + val answer = result.data + _state.update { + it.copy(live = answer, liveFailed = false, liveAt = System.currentTimeMillis()) + } + // A new map under the same server: the picture follows. + if (answer.mapKey != null && map.mapKey != null && answer.mapKey != map.mapKey) { + askMap(asked) + } + resolveRuns(answer, asked) + } + // Positions are kept on a failure, in Polling.kt's shape: the last + // answer stays drawn and the status line says the ask failed. + else -> _state.update { it.copy(liveFailed = true) } + } + } + } + + fun toggle(layer: MapLayer) { + _state.update { + val shown = if (layer in it.shown) it.shown - layer else it.shown + layer + // A card for something the reader just hid would describe a marker + // that is no longer on the map. + val selected = it.selected?.takeIf { m -> m.layer in shown } + it.copy(shown = shown, selected = selected) + } + } + + fun select(marker: MapMarker?) { + _state.update { it.copy(selected = marker) } + } + + private suspend fun askMap(asked: Int) { + val result = repository.map(serverId) + if (asked != generation) return + _state.update { current -> + // A failed RE-read after a new mapKey keeps the map that is drawn: + // the old picture under new positions is better than an error screen, + // and the next poll asks again. + if (result !is ApiResult.Ok && current.map is UiState.Success) current + else current.copy(map = result.toUiState()) + } + } + + private suspend fun resolveRuns(answer: RustMapLiveDto, asked: Int) { + val ids = eventRunIds(answer) + if (ids.isEmpty()) return + val slugs = resolver.resolve(ids) + val titles = resolver.titles(ids) + if (asked != generation) return + _state.update { it.copy(eventSlugs = slugs, eventTitles = titles) } + } +} + +/** True when the server sends this viewer at least one layer that moves. */ +val RustMapDto.anyLive: Boolean + get() = layers.world.visible || layers.events.visible || layers.players.visible || + layers.bases.visible || mates.visible diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt new file mode 100644 index 0000000..5552948 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -0,0 +1,663 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.compose.foundation.background +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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.RustEventListDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustTitleDto +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, the map when the module + * has one (D122), 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, + onOpenEvent: (slug: String, runId: String) -> 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, onOpenEvent, 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, + onOpenEvent: (slug: String, runId: String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxSize()) { + ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes) + + val tabs = ui.tabs + 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.MAP -> RustMapPanel(serverOnline = server.online, onOpenEvent = onOpenEvent) + 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.MAP -> R.string.rust_tab_map + RustTab.WIPES -> R.string.rust_tab_wipes +} + +@Composable +private fun ServerHeader( + server: RustServerDto, + selectedWipe: String?, + onSelectWipe: (String?) -> Unit, + wipes: UiState>, +) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall) + + describeWorld(server)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + nextWipeLine(server)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (server.online) { + StatusPill( + text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers), + tone = PillTone.Success, + ) + } else { + StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral) + } + Text( + text = lastReported(server), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // The wipe picker sits above the tabs because it filters two of them. It + // is absent until the wipe list has loaded — offering a filter with one + // option would look like a server that has only ever had one wipe. + val available = (wipes as? UiState.Success)?.data.orEmpty() + if (available.isNotEmpty()) { + WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe) + } + } +} + +@Composable +private fun WipeFilter( + wipes: List, + currentWipeId: String?, + selected: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // **Null is all time, and it is the default.** It is a real choice rather + // than an absent filter: all-time is the per-wipe rows summed, which is + // the answer to "who plays here", where a wipe is the answer to "who is + // winning now". + FilterChip( + selected = selected == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.rust_all_time)) }, + ) + wipes.forEach { wipe -> + val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId + FilterChip( + selected = selected == wipe.wipeId, + onClick = { onSelect(wipe.wipeId) }, + label = { + Text( + if (wipe.wipeId == currentWipeId) { + stringResource(R.string.rust_wipe_current, label) + } else { + label + }, + ) + }, + ) + } + } +} + +// ── Feed ────────────────────────────────────────────────────────────────── + +@Composable +private fun FeedPanel( + feed: Polled, + filterId: String, + onFilter: (String) -> Unit, + onRetry: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FEED_FILTERS.forEach { filter -> + FilterChip( + selected = filter.id == filterId, + onClick = { onFilter(filter.id) }, + label = { Text(filter.label) }, + ) + } + } + + when (val s = feed.state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) + is UiState.Success -> { + // Said once, above the rows, so a thin feed reads as withheld + // rather than as a quiet server (org lead: nothing names who is + // online by default). + if (s.data.presenceHidden) { + Text( + text = stringResource( + if (s.data.presenceAudience == "signed_in") { + R.string.rust_feed_presence_hidden_signin + } else { + R.string.rust_feed_presence_hidden_staff + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (s.data.events.isEmpty()) { + EmptyView(stringResource(R.string.rust_feed_empty)) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (feed.refreshFailed) { + item { RefreshFailedLine() } + } + items(s.data.events, 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) }, +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LeaderboardPanel( + state: UiState>, + sort: String, + onSort: (String) -> Unit, + onRetry: () -> Unit, +) { + when (state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(state.kind, onRetry = onRetry) + is UiState.Success -> if (state.data.isEmpty()) { + // **Empty is a real answer here and is not "no data".** All-time is + // the per-wipe rows summed, so a player who appears only in an older + // wipe drops out of the current one rather than reading zero — an + // empty board for a wipe means nobody scored on that map. + EmptyView(stringResource(R.string.rust_leaderboard_empty)) + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Row(Modifier.fillMaxWidth()) { + SectionLabel( + 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. + Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) { + Text( + text = playerLabel(row.name, row.steamId), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // M19: the chat titles this player holds now, under the + // name — the column is too narrow to put them beside it. + if (row.titles.isNotEmpty()) { + FlowRow( + Modifier.padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + row.titles.forEach { title -> TitleChip(title) } + } + } + } + RUST_COLUMNS.forEach { column -> + Text( + text = column.value(row), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } +} + +/** + * One chat title (M19): the operator's colour as the chip, and black or white + * ink chosen for it, as the website draws it. A colour this build cannot read + * falls back to the theme's own chip colours. + */ +@Composable +private fun TitleChip(title: RustTitleDto) { + val argb = titleArgb(title.color) + val background = argb?.let { Color(it) } ?: MaterialTheme.colorScheme.secondaryContainer + val ink = when { + argb == null -> MaterialTheme.colorScheme.onSecondaryContainer + titleInkIsDark(argb) -> Color.Black + else -> Color.White + } + Text( + text = title.text, + style = MaterialTheme.typography.labelSmall, + color = ink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) +} + +// ── Online ──────────────────────────────────────────────────────────────── + +@Composable +private fun OnlinePanel( + online: Polled, + serverOnline: Boolean, + onRetry: () -> Unit, +) { + when (val s = online.state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) + // Withheld is not empty. Below the operator's audience the server sends + // the count and no names, and an empty list rendered as "nobody is on" + // would be a false statement about a full server. + is UiState.Success -> if (s.data.hidden) { + val count = s.data.count + EmptyView( + pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" + + stringResource( + when (s.data.audience) { + "signed_in" -> R.string.rust_online_hidden_signin + "public" -> R.string.rust_online_hidden_public + else -> R.string.rust_online_hidden_staff + }, + ), + ) + } else if (s.data.players.isEmpty()) { + EmptyView( + stringResource( + if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline, + ), + ) + } 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.players, 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>, + 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, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt new file mode 100644 index 0000000..6480d03 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt @@ -0,0 +1,317 @@ +/* + * 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.RustEventListDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustWipeDto +import com.runicgateway.app.data.repository.Capability +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.data.repository.SiteCapabilitiesRepository +import com.runicgateway.app.data.repository.canUse +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 sections of a server's page (D13), in the website's order. [MAP] sits + * between Online and Wipes where the web has it (D122), and is shown only when + * the site's module declares `map`. + */ +enum class RustTab { + FEED, LEADERBOARD, ONLINE, MAP, WIPES; + + /** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */ + val wire: String get() = name.lowercase() + + companion object { + /** + * The tab the website calls [wire], or null for one the app does not have. + * The website also has `clans`, and a link to it is not a link to the feed. + */ + fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire } + } +} + +/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */ +object RustSort { + const val KILLS = "kills" + const val DEATHS = "deaths" + const val NPC_KILLS = "npcKills" + const val PLAYTIME = "playtime" +} + +/** + * Everything one server's page is showing. + * + * One state object rather than eight flows: every panel on the page is about the + * same server and the same selected wipe, and a screen that collected them + * separately could render a leaderboard for one wipe beside a feed for another + * for a frame. + */ +data class RustServerUi( + val serverId: String = "", + val server: Polled = Polled(), + val tab: RustTab = RustTab.FEED, + val filterId: String = "all", + val sort: String = RustSort.KILLS, + /** The wipe every panel is filtered to. **Null is all time**, not "unknown". */ + val selectedWipe: String? = null, + /** The whole answer, not its rows: `presenceHidden` is part of what it says. */ + val feed: Polled = Polled(), + /** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */ + val online: Polled = Polled(), + val leaderboard: UiState> = UiState.Loading, + val wipes: UiState> = UiState.Loading, + /** Whether the Map tab is offered, under the app's one capability rule (D122). */ + val mapAvailable: Boolean = true, +) { + /** The tabs this site offers, in order. */ + val tabs: List get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable } +} + +/** + * 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, + capabilities: SiteCapabilitiesRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: "" + + /** The tab a link asked for (D71), or null for the feed every page opens on. */ + private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB]) + + private val _state = MutableStateFlow(RustServerUi(serverId = serverId)) + val state: StateFlow = _state.asStateFlow() + + init { + load() + // Before the tab a link asked for, so `?tab=map` on a site without a map + // is refused by [selectTab] and opens the feed. + viewModelScope.launch { + capabilities.capabilities.collect { caps -> + val available = canUse(caps, Capability.MAP) + _state.update { + // The tab going away under the reader lands them on the feed, + // which every page has and has already loaded. + val tab = if (!available && it.tab == RustTab.MAP) RustTab.FEED else it.tab + it.copy(mapAvailable = available, tab = tab) + } + } + } + initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab) + } + + /** 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() + // The map keeps its own cadence (D124) in [RustMapViewModel]. + RustTab.LEADERBOARD, RustTab.WIPES, RustTab.MAP -> 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 + if (tab !in already.tabs) return + _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() + // Loaded by its own view model when the tab composes. + RustTab.MAP -> Unit + } + } + } + + /** A different question for the feed: blank it and ask. */ + fun selectFilter(filterId: String) { + if (filterId == _state.value.filterId) return + _state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) } + viewModelScope.launch { askFeed() } + } + + /** A different question for the leaderboard: blank it and ask. */ + fun selectSort(sort: String) { + if (sort == _state.value.sort) return + _state.update { it.copy(sort = sort, leaderboard = UiState.Loading) } + viewModelScope.launch { askLeaderboard() } + } + + /** + * Pick a wipe, or all time with null. + * + * It is the one selection that changes **two** panels, so both are blanked — + * and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab + * does not fetch a leaderboard nobody has opened. + */ + fun selectWipe(wipeId: String?) { + if (wipeId == _state.value.selectedWipe) return + + val hadLeaderboard = _state.value.leaderboard is UiState.Success + _state.update { + it.copy( + selectedWipe = wipeId, + feed = Polled(UiState.Loading), + leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard, + ) + } + + viewModelScope.launch { + askFeed() + if (hadLeaderboard) askLeaderboard() + } + } + + /** + * Pick a wipe from the Wipes tab, which is a navigation as much as a filter. + * + * The question it asks is "what happened during that map", and the answer is + * the feed — so it lands there rather than leaving the reader on a list of + * dates with nothing visibly changed. + */ + fun openWipe(wipeId: String) { + selectWipe(wipeId) + selectTab(RustTab.FEED) + } + + /** + * Retry one panel after its own load failed. + * + * Four entry points rather than one, because a failed leaderboard is not a + * reason to re-read the feed the reader can already see — and [load] is the + * whole page, which is right for a failed *server* read and heavy-handed for + * anything else. + */ + fun retryFeed() { + _state.update { it.copy(feed = Polled(UiState.Loading)) } + viewModelScope.launch { askFeed() } + } + + fun retryLeaderboard() { + _state.update { it.copy(leaderboard = UiState.Loading) } + viewModelScope.launch { askLeaderboard() } + } + + fun retryOnline() { + _state.update { it.copy(online = Polled(UiState.Loading)) } + viewModelScope.launch { askOnline() } + } + + fun retryWipes() { + _state.update { it.copy(wipes = UiState.Loading) } + viewModelScope.launch { askWipes() } + } + + private suspend fun askServer() { + val result = repository.server(serverId) + _state.update { it.copy(server = refreshInto(it.server.state, result)) } + } + + private suspend fun askFeed() { + val current = _state.value + val result = repository.events( + id = serverId, + kinds = kindsFor(current.filterId), + wipe = current.selectedWipe, + limit = FEED_LIMIT, + ) + _state.update { it.copy(feed = refreshInto(it.feed.state, result)) } + } + + private suspend fun askOnline() { + val result = repository.online(serverId) + _state.update { it.copy(online = refreshInto(it.online.state, result)) } + } + + private suspend fun askLeaderboard() { + val current = _state.value + val result = repository.leaderboard( + id = serverId, + wipe = current.selectedWipe, + sort = current.sort, + limit = LEADERBOARD_LIMIT, + ) + _state.update { it.copy(leaderboard = result.toUiState()) } + } + + private suspend fun askWipes() { + _state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) } + } + + private companion object { + /** Matches the website's feed page size; the server caps at 200 regardless. */ + const val FEED_LIMIT = 100 + const val LEADERBOARD_LIMIT = 50 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt new file mode 100644 index 0000000..42ed4d6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt @@ -0,0 +1,216 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.ui.PollWhileResumed +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.EmptyView +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard +import com.runicgateway.app.ui.components.StatusPill + +/** + * Every Rust server this site follows — the module's landing page (D8, D12). + * + * **The phase criterion is this screen with every server off.** Nothing here is a + * live call to a game host: the website answers from its own tables, so a fleet + * that has been down for a week renders a week of last-known state rather than an + * error. The one thing that can fail is the website itself. + */ +@Composable +fun RustServersScreen( + onOpenServer: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: RustServersViewModel = hiltViewModel(), +) { + val polled by viewModel.state.collectAsStateWithLifecycle() + + PollWhileResumed { viewModel.refresh() } + + when (val s = polled.state) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Success -> { + if (s.data.isEmpty()) { + EmptyView(stringResource(R.string.rust_servers_empty), modifier) + } else { + ServerList(s.data, polled.refreshFailed, onOpenServer, modifier) + } + } + } +} + +@Composable +private fun ServerList( + servers: List, + refreshFailed: Boolean, + onOpenServer: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // A failed refresh says so and changes nothing else. The rows below it are + // the last good answer and stay exactly as they were — blanking them is + // the one thing a site whose premise is "it renders while the game is off" + // must not do when a request fails. + if (refreshFailed) { + item { + Text( + text = stringResource(R.string.rust_refresh_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + items(servers, key = { it.id }) { server -> + ServerRow(server) { onOpenServer(server.id) } + } + } +} + +@Composable +private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) { + ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) { + // `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), + ) + } + + nextWipeLine(server)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.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(" · ") +} + +/** + * "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off + * date — or null, and the caller draws no line, when the server has no schedule + * or the module predates phase 16 and sends no field. + */ +@Composable +internal fun nextWipeLine(server: RustServerDto): String? { + val next = server.nextWipe ?: return null + val whenText = nextWipeWhen(next.at) ?: return null + return if (next.source == "once") { + stringResource(R.string.rust_next_wipe_rescheduled, whenText) + } else { + stringResource(R.string.rust_next_wipe, whenText) + } +} + +/** + * "last reported 3 minutes ago". + * + * **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact + * confusion and fixed it in phase 4: `updatedAt` moves on every poll including a + * failed one, so an offline server claimed it had just checked in, every thirty + * seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`. + */ +@Composable +internal fun lastReported(server: RustServerDto): String { + val ago = rustAgo(server.lastSeenAt) + ?: return stringResource(R.string.rust_never_reported) + + return if (server.stale) { + stringResource(R.string.rust_last_reported_stale, ago) + } else { + stringResource(R.string.rust_last_reported, ago) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersViewModel.kt new file mode 100644 index 0000000..92a86e3 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersViewModel.kt @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.Polled +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.refreshInto +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The Rust server list — the module's landing page, one tier along (PLAN.md §9 + * M14; `docs/modules/rust/PLAN.md` D12). + * + * **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature` + * gate, so a `404` here is a genuinely missing thing and never an admin's + * visibility switch. Offering "this shard doesn't publish it" for one would name + * a cause that does not exist on this surface. + * + * [refresh] is what the screen's poll calls and [load] is what a retry calls, and + * the difference is the whole of [refreshInto]: a refresh keeps the rows when it + * fails, a load is allowed to blank them because there is nothing on screen to + * protect. + */ +@HiltViewModel +class RustServersViewModel @Inject constructor( + private val repository: RustRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(Polled>()) + val state: StateFlow>> = _state.asStateFlow() + + /** A first load or a retry: show the spinner, then replace whatever comes back. */ + fun load() { + _state.value = Polled(UiState.Loading) + viewModelScope.launch { ask() } + } + + /** A poll: silent on success, and it keeps the rows on failure. */ + fun refresh() { + viewModelScope.launch { ask() } + } + + private suspend fun ask() { + _state.value = refreshInto(_state.value.state, repository.servers()) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ace8450..bd9cafd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,9 +4,14 @@ All user-facing strings are externalized from day one (PLAN.md §2): English is the only bundled locale, but the structure invites community translations. No hardcoded UI strings in Kotlin. + + The strings are split by owner (PLAN.md M20), and Android merges the three files + into one R.string: this file is CORE — every screen any deployment can show — and + uses core's words only. A game's words live in strings_uo.xml and strings_rust.xml. + StringVocabularyTest fails the build when a game's word lands in the wrong file. --> - + Runic Gateway @@ -15,22 +20,22 @@ Can\'t reach the site. Check your connection and try again. This content couldn\'t be found. Too many requests. Please try again in a moment. - The shard is offline right now. - This shard doesn\'t publish this here. + The game server is offline right now. + This site doesn\'t publish this here. Something went wrong on the server. Please try again. - Connect to your shard - Enter your shard\'s website address to get started. + Connect to your community + Enter your community\'s website address to get started. Website address - https://your-shard.example.com + https://your-community.example.com Connect Connecting… Enter a website address. That doesn\'t look like a valid web address. Only http and https addresses are supported. A secure https address is required. - That site isn\'t a Runic Gateway shard. + That site isn\'t a Runic Gateway site. This app is out of date for that site (it speaks API %1$s). Update the app and try again. Couldn\'t reach that site. Check the address and your connection. The site responded with an error (%1$d). Try again shortly. @@ -43,22 +48,12 @@ News Events Wiki - Shard - Rules - Atlas - Leaderboards - Market About Contact My account My events - My characters - My vendors - My houses Dashboard Content - Moderation - Support queue Sign in Sign out Change server @@ -109,41 +104,9 @@ A title is required. Slug and title are required. - - Account action - Account - Serial (0x…) - Reason (ban) - Ban duration (seconds; blank = indefinite) - Kick - Ban - Unban - Broadcast - Message to everyone online - Broadcast - Account kicked. - Account banned. - Ban cleared. - Message broadcast. - Enter an account or serial. - Enter a message to broadcast. - The shard is offline — the action couldn\'t be delivered. - - - No open help pages. - Reply - Close - Send - Reply message - Close the page after replying - Reply sent. - Page closed. - Enter a reply message. - That page is no longer in the queue. - Sign in - Sign in with your shard account. + Sign in with your account on this site. Username Password Authentication code @@ -151,7 +114,7 @@ Sign in Create an account Forgot your password? - + Sign in with SSO Choose a sign-in provider @@ -207,6 +170,8 @@ Linked accounts No linked sign-in providers. Unlink + + %1$s: %2$s Account unlinked. Couldn\'t unlink that account. @@ -253,77 +218,7 @@ Share Done - - Link your game account - In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses. - Link code - Link account - Account linked. - That code is unknown or has expired. Run [link in game for a new one. - Couldn\'t link that code. Please try again. - - Create a game account - Account name - Password - Create account - Game account created and linked. - That account name is already taken. - The account limit for your network has been reached. - Game-account signup isn\'t available right now. - That account name or password wasn\'t accepted. - Couldn\'t create that account. Please try again. - - - Link a game account above to see your characters. - Loading… - No characters on this account. - Unknown - Online - Offline - - - Governor of %1$s - Guildmaster, %1$s - Attributes - Vitals - STR - DEX - INT - Hits - Mana - Stamina - Resistances - Physical - Fire - Cold - Poison - Energy - Skills - Loyalty & Points - - %1$s · #%2$d - - %1$d / %2$d - Equipment - Item - id %1$d - hue %1$d - - - Recent sales - No recent sales. - %1$s gp - Link a game account to see your vendors. - No vendors on this account. - Player vendor - Holding %1$s gp - No items listed. - Item %1$d ×%2$d - - - You don\'t have any houses on your linked accounts. - A house - Status: %1$s + Player Moderator Editor @@ -362,140 +257,19 @@ Please fill in every field. Couldn\'t send your message. Please try again. - - Live - Offline - Shard online - Shard offline - %1$d players online - Gold supply: %1$s - Staff online - Live activity - No recent activity. - Champion spawns - Guilds - Governors - Falling houses - - - No champion spawns are being tracked right now. - Champion spawn - Level %1$d - Restarting - - - No guilds are being tracked right now. - Guild - %1$d members - Led by %1$s - Alliance: %1$s - - - - This shard hasn\'t published its ruleset yet. - Shard - Systems - Skill & stat caps - Accounts & housing - Vendors - Saves & restarts - Name - Expansion - Connect - Individual skill cap - Total skill cap - Total stat cap - Strength cap - Dexterity cap - Intelligence cap - Character slots - Accounts per IP - Houses per account - Vendor restock delay - Max sell quantity - Auto-save every - Auto-restart at - %1$d min - - - This shard isn\'t publishing any leaderboards yet. - Nobody has scored here yet. - - — - %1$d players - - Cap: %1$d - #%1$d %2$s - - Someone - - - Search every shop - No listings match that search. - This shop has nothing for sale. - - Prices are refreshed in rotation and may be out of date. - %1$d gp - Item %1$d - A shop - Kept by %1$s - - This shard doesn\'t publish shop locations. - Showing %1$d of %2$d — this shop holds more than the shard publishes. - - - Search creatures - No creatures match that search. - - - %1$d spawner - %1$d spawners - - Up to %1$d alive at once - %1$s (%2$d) - - Where it spawns - up to %1$d at once - Spawn points - Also spawns here - More spawn points than shown. - Up to %1$d - - Respawn %1$s - - - No governors — this shard may not run the City Loyalty system. - Governed by %1$s - No governor - Election: %1$s - Term history - Loading… - No past terms recorded. - - - No houses are in danger right now. - A house - IDOC - Notifications Notifications - Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on. + Choose what this site notifies you about, and how it reaches you. Nothing is sent unless you turn it on. General Your game account - Link a game account to enable this. - This shard offers no notification streams yet. - This shard hasn\'t set up push notifications yet. + Link a game account to get this as a push notification. + This site offers no notification streams yet. + This site hasn\'t set up push notifications yet. Notification settings saved. Notifications turned off. Couldn\'t save your notification settings. Try again. - This shard\'s push relay isn\'t reachable right now. + This site\'s push relay isn\'t reachable right now. Off @@ -510,22 +284,15 @@ Offline — showing what was saved on this device. - Shard notifications - Alerts you opted into from this shard. + Notifications from your community + Alerts you opted into from this site. Background connection Keeps the connection open to deliver notifications. Notifications active - Listening for shard notifications. + Listening for notifications from your community. New post - Shard status changed - A house is falling (IDOC) - Champion spawn started - New governor elected - Your vendor made a sale - Your house entered IDOC - Login to your account New notification @@ -549,6 +316,8 @@ Part of %1$s Next Under way + %1$s: %2$d of %3$d left + %1$s: %2$d%% Nothing scheduled at the moment. This event has not been scheduled yet. Coming up diff --git a/app/src/main/res/values/strings_rust.xml b/app/src/main/res/values/strings_rust.xml new file mode 100644 index 0000000..6f92576 --- /dev/null +++ b/app/src/main/res/values/strings_rust.xml @@ -0,0 +1,157 @@ + + + + + + Rust servers + + + No Rust servers are configured on this site yet. + Offline + %1$d / %2$d online + has never reported + last reported %1$s + last reported %1$s — out of date, so it is shown as offline + size %1$d + seed %1$d + wiped %1$s + Next wipe %1$s + Next wipe %1$s (rescheduled) + Could not refresh just now. This is the last thing the site heard. + No such server + This address does not name a server this site follows. + Back to the server list + Feed + Leaderboard + Online + Wipes + Map + + No map yet. This server has not told the site which map it is on. The map will appear once the server is up. + This server has no picture of its map, so the layers are drawn on a plain background. + The picture of the map could not be loaded, so the layers are drawn on a plain background. + Map of the server + No moving layers are shown to you on this server. + Asking the server where things are… + The server did not say where things are just now. The site asks again every ten seconds. + The server is offline, so nothing is moving on its map. + Positions could not be loaded. + Could not refresh just now. These are the last positions the site heard. + Positions as of %1$s. They refresh every ten seconds while this tab is open. + Grid + Monuments & world events + Site events + Players + Bases + You and your clan + Sign in to see this layer. + Shown to everyone. + Shown to staff only. + %1$s Limited by who may see who is online. + Not every sleeper is shown. + Not every base is shown. + Your own position, and clan mates who are online. + Link your Steam account to see yourself and your clan here. + Cargo ship + Patrol helicopter + Chinook + Bradley APC + Supply drop + Locked crate + %1$s left on the hack + Hacked + Event zone + Event crate + Event NPC + Site event + Site event · %1$s + %1$s (boss) + Boss + %1$s (a boss\'s add) + Open event + Tool cupboard + Vending machine + Sleeping + Asleep, offline + You + Clan mate + Grid %1$s + Close + All time + %1$s (this wipe) + Current + Showing + Nothing has happened on this server yet — or not during the wipe you are looking at. + Nobody has scored here yet. + This server has not reported a wipe yet. + + + %1$d player online + %1$d players online + + Only this site’s staff can see who they are. + Sign in to see who they are. + This site is not showing who they are right now. + Joins, deaths and chat are not shown. Only this site’s staff can see what players did. + Joins, deaths and chat are not shown. Sign in to see what players did. + The server is up and the island is empty. Somebody has to be first. + Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is. + On the server right now. + The last board this server sent. It is offline, so this is who was on then — not who is on now. + Sleeping + Player + Kills + Deaths + NPC + Built + Played + %1$d players online + + + My Rust account + Link your Steam account + Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once. + Link code + Link account + Linked. Your play on our servers now appears under your name here. + That account was already linked to you. + That code is unknown or has expired. Type /link in game for a new one. + That Steam account is linked to another website account. Run /unlink in game to release it. + Too many attempts just now. Try again in a few minutes. + A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute. + Could not link that code. + Unlink + Could not unlink that account. + linked %1$s + No Steam account is linked to this profile yet. + A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game. + What you can do in game + Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover. + None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync. + A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back. + No servers are configured yet. + Coins + Link a Steam account to see your balance. + Economics is not installed on this server. + %1$s · %2$s coins + Last known — the server did not answer just now. + Backpack + Link a Steam account to see your backpack. + Open backpack + Read again + Close + Backpacks is not installed on this server. + This site cannot show backpacks yet. + No backpack here yet — it has never been opened. + %1$d stacks in %2$d slots + %1$d past your size — they move to your inventory, or drop at your feet if it is full, the next time you open it. + Page %1$d + %1$s · has it + %1$s · waiting + diff --git a/app/src/main/res/values/strings_uo.xml b/app/src/main/res/values/strings_uo.xml new file mode 100644 index 0000000..deaa7b0 --- /dev/null +++ b/app/src/main/res/values/strings_uo.xml @@ -0,0 +1,256 @@ + + + + + + Shard + Rules + Atlas + Leaderboards + Market + My characters + My vendors + My houses + Moderation + Support queue + + + + Account action + Account + Serial (0x…) + Reason (ban) + Ban duration (seconds; blank = indefinite) + Kick + Ban + Unban + Broadcast + Message to everyone online + Broadcast + Account kicked. + Account banned. + Ban cleared. + Message broadcast. + Enter an account or serial. + Enter a message to broadcast. + The shard is offline — the action couldn\'t be delivered. + + + No open help pages. + Reply + Close + Send + Reply message + Close the page after replying + Reply sent. + Page closed. + Enter a reply message. + That page is no longer in the queue. + + + Link your game account + In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses. + Link code + Link account + Account linked. + That code is unknown or has expired. Run [link in game for a new one. + Couldn\'t link that code. Please try again. + + Create a game account + Account name + Password + Create account + Game account created and linked. + That account name is already taken. + The account limit for your network has been reached. + Game-account signup isn\'t available right now. + That account name or password wasn\'t accepted. + Couldn\'t create that account. Please try again. + + + Link a game account above to see your characters. + Loading… + No characters on this account. + Unknown + Online + Offline + + + Governor of %1$s + Guildmaster, %1$s + Attributes + Vitals + STR + DEX + INT + Hits + Mana + Stamina + Resistances + Physical + Fire + Cold + Poison + Energy + Skills + Loyalty & Points + + %1$s · #%2$d + + %1$d / %2$d + Equipment + Item + id %1$d + hue %1$d + + + Recent sales + No recent sales. + %1$s gp + Link a game account to see your vendors. + No vendors on this account. + Player vendor + Holding %1$s gp + No items listed. + Item %1$d ×%2$d + + + You don\'t have any houses on your linked accounts. + A house + Status: %1$s + + + Live + Offline + Shard online + Shard offline + %1$d players online + Gold supply: %1$s + Staff online + Live activity + No recent activity. + Champion spawns + Guilds + Governors + Falling houses + + + No champion spawns are being tracked right now. + Champion spawn + Level %1$d + Restarting + + + No guilds are being tracked right now. + Guild + %1$d members + Led by %1$s + Alliance: %1$s + + + + This shard hasn\'t published its ruleset yet. + Shard + Systems + Skill & stat caps + Accounts & housing + Vendors + Saves & restarts + Name + Expansion + Connect + Individual skill cap + Total skill cap + Total stat cap + Strength cap + Dexterity cap + Intelligence cap + Character slots + Accounts per IP + Houses per account + Vendor restock delay + Max sell quantity + Auto-save every + Auto-restart at + %1$d min + + + This shard isn\'t publishing any leaderboards yet. + Nobody has scored here yet. + + — + %1$d players + + Cap: %1$d + #%1$d %2$s + + Someone + + + Search every shop + No listings match that search. + This shop has nothing for sale. + + Prices are refreshed in rotation and may be out of date. + %1$d gp + Item %1$d + A shop + Kept by %1$s + + This shard doesn\'t publish shop locations. + Showing %1$d of %2$d — this shop holds more than the shard publishes. + + + Search creatures + No creatures match that search. + + + %1$d spawner + %1$d spawners + + Up to %1$d alive at once + %1$s (%2$d) + + Where it spawns + up to %1$d at once + Spawn points + Also spawns here + More spawn points than shown. + Up to %1$d + + Respawn %1$s + + + No governors — this shard may not run the City Loyalty system. + Governed by %1$s + No governor + Election: %1$s + Term history + Loading… + No past terms recorded. + + + No houses are in danger right now. + A house + IDOC + + + Shard status changed + A house is falling (IDOC) + Champion spawn started + New governor elected + Your vendor made a sale + Your house entered IDOC + Login to your account + diff --git a/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt b/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt new file mode 100644 index 0000000..dc60bd3 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt @@ -0,0 +1,168 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * The guard on the app's words (PLAN.md M20). A game's words stay on that game's + * screens, and the screens every deployment shows use core's words. This is the + * app's half of the rule the website docs and runicgateway.com already follow + * (runicgateway.com PLAN.md §16, D63/D64). + * + * The strings are split by owner into three resource files that Android merges as + * one `R.string`: `strings.xml` (core), `strings_uo.xml` and `strings_rust.xml`. + * A string's owner is the screens that use it, not its key: a string any screen a + * Rust deployment can reach is core, even when its key says `shard_`. This test + * reads the three files and fails, naming the key and the word, when + * - the core file holds either game's word; + * - the UO file holds a Rust word; + * - the Rust file holds a UO word. + * + * It checks every `values*` directory, so a translation is held to the same rule. + * Comments are not read, because nobody using the app sees them. + */ +class StringVocabularyTest { + + private enum class Owner(val file: String, val label: String) { + CORE("strings.xml", "core"), + UO("strings_uo.xml", "Ultima Online"), + RUST("strings_rust.xml", "Rust"), + } + + private class Term(val game: Owner, val word: String, pattern: String) { + val regex = Regex(pattern, RegexOption.IGNORE_CASE) + } + + /** + * runicgateway.com's `scripts/checkTerms.mjs` list, word for word. Each word is a game's + * name for something core names differently (shard → game server, guild and clan → + * Team), or the name of one game's component. Game NAMES (Ultima Online, Rust) are not + * on it, and neither is Steam, which is a core sign-in provider. Matching is on word + * boundaries, so "wipe" does not match "wiped" and "shard" does not match `SHARD_X`. + */ + private val terms = listOf( + Term(Owner.UO, "shard", """\bshards?\b"""), + Term(Owner.UO, "ServUO", """\bservuo\b"""), + Term(Owner.UO, "uo-link", """\buo-?link\b"""), + Term(Owner.UO, "Module-uo", """\bmodule-uo\b"""), + Term(Owner.UO, "Bridge.cfg", """\bbridge\.cfg\b"""), + Term(Owner.UO, "cliloc", """\bclilocs?\b"""), + Term(Owner.UO, "guild", """\bguilds?\b"""), + Term(Owner.UO, "IDOC", """\bidocs?\b"""), + Term(Owner.RUST, "rust-link", """\brust-?link\b"""), + Term(Owner.RUST, "Rust-Plugins", """\brust-plugins\b"""), + Term(Owner.RUST, "Module-Rust", """\bmodule-rust\b"""), + Term(Owner.RUST, "Oxide", """\boxide\b"""), + Term(Owner.RUST, "Carbon", """\bcarbon\b"""), + Term(Owner.RUST, "uMod", """\bumod\b"""), + Term(Owner.RUST, "clan", """\bclans?\b"""), + Term(Owner.RUST, "wipe", """\bwipes?\b"""), + ) + + /** The words [owner]'s file may not use. */ + private fun forbiddenIn(owner: Owner): List = when (owner) { + Owner.CORE -> terms + Owner.UO -> terms.filter { it.game == Owner.RUST } + Owner.RUST -> terms.filter { it.game == Owner.UO } + } + + /** The words in [text] that [owner]'s file may not use. */ + private fun hits(owner: Owner, text: String): List> = + forbiddenIn(owner).flatMap { term -> term.regex.findAll(text).map { term to it.value }.toList() } + + /** + * The module's `res`, wherever the test runs from: Gradle runs unit tests in the + * module directory, and an IDE may run them from the repository root. + */ + private val res: File = listOf(File("src/main/res"), File("app/src/main/res")) + .firstOrNull { it.isDirectory } + ?: error("Can't find src/main/res from ${File(".").absolutePath}") + + private val valuesDirs: List + get() = res.listFiles { f -> f.isDirectory && f.name.startsWith("values") }!!.sortedBy { it.name } + + /** Every resource in [file] that the app shows: key → its visible text. */ + private fun visibleText(file: File): List> { + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val children = doc.documentElement.childNodes + return (0 until children.length) + .map { children.item(it) } + .filterIsInstance() + // A plural's items arrive as one text, which is all a word scan needs. + .map { it.getAttribute("name") to it.textContent } + } + + @Test fun eachFileUsesOnlyItsOwnGamesWords() { + val failures = mutableListOf() + for (dir in valuesDirs) { + for (owner in Owner.entries) { + val file = File(dir, owner.file) + if (!file.exists()) continue + for ((key, text) in visibleText(file)) { + for ((term, found) in hits(owner, text)) { + failures += "${dir.name}/${owner.file}: $key uses \"$found\", " + + "${term.game.label} vocabulary, in the ${owner.label} file" + } + } + } + } + if (failures.isNotEmpty()) { + fail( + failures.joinToString( + separator = "\n ", + prefix = "${failures.size} game word(s) outside their game's file:\n ", + postfix = "\n\nPLAN.md M20: a string any deployment's screen shows is core, and uses " + + "core's words (site, community, game server, Team). Reword it, or, if only one " + + "game's screens use it, move it to that game's file.", + ), + ) + } + } + + /** + * A fourth strings file would be read by Android and skipped by this guard, so it + * is refused. A new game gets its own file and its own row in [Owner]. + */ + @Test fun everyStringsFileHasAnOwner() { + val known = Owner.entries.map { it.file }.toSet() + val strays = valuesDirs.flatMap { dir -> + dir.listFiles { f -> f.name.startsWith("strings") && f.name !in known }!!.map { "${dir.name}/${it.name}" } + } + assertEquals("Strings files with no owner in StringVocabularyTest", emptyList(), strays) + } + + /** Without this a wrong path would let the guard pass on nothing. */ + @Test fun theDefaultFilesAreAllThere() { + for (owner in Owner.entries) { + val file = File(res, "values/${owner.file}") + assertTrue("${file.path} is missing", file.exists()) + assertTrue("${file.path} holds no strings", visibleText(file).isNotEmpty()) + } + } + + /** The matcher itself, so a broken pattern can't make every file look clean. */ + @Test fun theWordListCatchesWhatItShould() { + fun words(owner: Owner, text: String) = hits(owner, text).map { it.first.word } + + assertEquals(listOf("shard"), words(Owner.CORE, "Connect to your shard")) + assertEquals(listOf("guild"), words(Owner.CORE, "Guilds")) + assertEquals(listOf("clan"), words(Owner.CORE, "You and your clan")) + assertEquals(listOf("wipe"), words(Owner.UO, "Next wipe %1\$s")) + assertEquals(listOf("IDOC"), words(Owner.RUST, "Your house entered IDOC")) + // Each game's own words are fine in its own file. + assertEquals(emptyList(), words(Owner.UO, "Shard online")) + assertEquals(emptyList(), words(Owner.RUST, "Clan mate")) + // Word boundaries: not a prefix, not an identifier. + assertEquals(emptyList(), words(Owner.CORE, "wiped %1\$s")) + assertEquals(emptyList(), words(Owner.CORE, "Guildmaster")) + assertEquals(emptyList(), words(Owner.CORE, "SHARD_VISIBILITY")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/core/push/PushContentTest.kt b/app/src/test/java/com/runicgateway/app/core/push/PushContentTest.kt new file mode 100644 index 0000000..97ff770 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/push/PushContentTest.kt @@ -0,0 +1,101 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.push + +import com.runicgateway.app.data.api.dto.NotificationInboxDto +import com.runicgateway.app.data.api.dto.NotificationItemDto +import com.runicgateway.app.data.api.fake.FakeNotificationsApi +import com.runicgateway.app.data.repository.NotificationsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * A tickle titled from the inbox row it points at (`docs/modules/rust/PLAN.md` + * D70), and every way that falls back to the per-stream title. + */ +class PushContentTest { + + private val raid = NotificationItemDto( + id = 42, + triggerId = "rust.base.destroyed", + title = "Your base is being raided", + body = "A door was destroyed in S16 on Oxide rig.", + ) + private val raidTickle = PushTickle(stream = "rust.base.destroyed", ref = "notification:42") + + // ── The ref ──────────────────────────────────────────────────────────── + + @Test fun aRefNamesAnInboxIdOnlyWhenItIsExactlyOne() { + assertEquals(42L, inboxIdFromRef("notification:42")) + assertNull(inboxIdFromRef(null)) + assertNull(inboxIdFromRef("notification:")) + assertNull(inboxIdFromRef("notification:0")) + assertNull(inboxIdFromRef("notification:-4")) + assertNull(inboxIdFromRef("notification:4a")) + assertNull(inboxIdFromRef("notification: 4")) + assertNull(inboxIdFromRef("xnotification:4")) + assertNull(inboxIdFromRef("0x24C")) + assertNull(inboxIdFromRef("notification:" + "9".repeat(40))) + } + + // ── Which row ────────────────────────────────────────────────────────── + + @Test fun theRowMustBeTheNamedOneFromTheNamedTrigger() { + val other = raid.copy(id = 41, title = "An older raid") + assertEquals(raid, itemForTickle(raidTickle, listOf(other, raid))) + // Same id, different trigger: the tickle disagrees with the row. + assertNull(itemForTickle(raidTickle.copy(stream = "rust.server.online"), listOf(raid))) + assertNull(itemForTickle(raidTickle.copy(ref = "notification:7"), listOf(raid))) + assertNull(itemForTickle(raidTickle.copy(ref = null), listOf(raid))) + } + + // ── What the notification says ───────────────────────────────────────── + + @Test fun theRowsTitleAndBodyAreUsed() { + val text = notificationText(raid, generic = "New notification") + assertEquals("Your base is being raided", text.title) + assertEquals("A door was destroyed in S16 on Oxide rig.", text.body) + } + + @Test fun noRowOrABlankTitleFallsBackToTheStreamTitle() { + assertEquals(NotificationText("New notification", null), notificationText(null, "New notification")) + assertEquals( + NotificationText("New notification", null), + notificationText(raid.copy(title = " "), "New notification"), + ) + assertEquals(null, notificationText(raid.copy(body = " "), "x").body) + } + + // ── The pull ─────────────────────────────────────────────────────────── + + private val api = FakeNotificationsApi() + private val resolver = PushContentResolver(NotificationsRepository(api)) + + @Test fun theResolverPullsTheFirstPage() = runTest { + api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid), unread = 1)) + + assertEquals(raid, resolver.itemFor(raidTickle)) + assertEquals(listOf(null), api.inboxCalls) + } + + @Test fun aTickleWithNoInboxRefIsNeverPulled() = runTest { + // A classic M7 stream, or a push-only rule with no inbox row. + assertNull(resolver.itemFor(PushTickle(stream = "news.post", ref = "0x24C"))) + assertTrue(api.inboxCalls.isEmpty()) + } + + @Test fun aFailedPullIsNullNotAnError() = runTest { + api.error = IOException("offline") + assertNull(resolver.itemFor(raidTickle)) + } + + @Test fun aRowNotOnThePageIsNull() = runTest { + api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid.copy(id = 99)))) + assertNull(resolver.itemFor(raidTickle)) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt new file mode 100644 index 0000000..915d075 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Nothing names who is online by default (org lead, 2026-09-22). These are the + * answers module-rust's public routes give below and inside the operator's + * presence audience, copied from a live walk against the module — and the old + * shape, from a core running a module that predates the flag. + */ +class RustPresenceDtoTest { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + @Test fun withheldOnlineCarriesTheCountAndNoNames() { + val dto = json.decodeFromString( + """{"players":[],"hidden":true,"count":2,"audience":"staff"}""", + ) + assertTrue(dto.hidden) + assertEquals(2, dto.count) + assertEquals("staff", dto.audience) + assertTrue(dto.players.isEmpty()) + } + + @Test fun visibleOnlineNamesThePlayers() { + val dto = json.decodeFromString( + """{"players":[{"steamId":"76561198000000002","name":"Builder Bea","sleeping":false, + "connectedAt":"2026-09-23T05:02:30.000Z"}],"hidden":false,"count":1,"audience":"staff"}""", + ) + assertFalse(dto.hidden) + assertEquals("Builder Bea", dto.players.single().name) + } + + @Test fun anOlderModuleWithoutTheFlagReadsAsVisible() { + val dto = json.decodeFromString("""{"players":[]}""") + assertFalse(dto.hidden) + assertNull(dto.audience) + } + + @Test fun aWithheldFeedSaysSo() { + val dto = json.decodeFromString( + """{"events":[{"id":11,"kind":"server.wipe","t":1789500000000,"wipeId":"w-20260920T000000Z", + "steamId":null,"frame":{}}],"presenceHidden":true,"presenceAudience":"signed_in"}""", + ) + assertTrue(dto.presenceHidden) + assertEquals("signed_in", dto.presenceAudience) + assertEquals("server.wipe", dto.events.single().kind) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt index 2003373..fc979d5 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt @@ -38,8 +38,13 @@ class FakeEventsApi : EventsApi { return value } - override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto = - reply(calendar) + /** How many times the calendar was read, for the Rust map's run resolver. */ + var calendarCalls: Int = 0 + + override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto { + calendarCalls++ + return reply(calendar) + } override suspend fun getEvent(slug: String, run: String?): PublicEventResponse { lastSlug = slug diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt new file mode 100644 index 0000000..1ad6cab --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerRustApi.kt @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.fake + +import com.runicgateway.app.data.api.PlayerRustApi +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustEconomicsDto +import com.runicgateway.app.data.api.dto.RustLinkListDto +import com.runicgateway.app.data.api.dto.RustLinkRequest +import com.runicgateway.app.data.api.dto.RustLinkResultDto +import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.dto.RustUnlinkResultDto + +/** + * A configurable fake of [PlayerRustApi] (M15). + * + * [linkError] is its own field rather than a shared [error]: the tests that + * matter here are about a **redemption** that fails while the reads around it + * succeed — a refused code must leave the accounts and entitlements on screen, + * and one `error` for the whole interface could not express that. + */ +class FakePlayerRustApi : PlayerRustApi { + + /** Thrown by every call — the "the site is down" case. */ + var error: Throwable? = null + + /** Thrown by one call each, so one half of the screen can fail alone. */ + var linksError: Throwable? = null + var linkError: Throwable? = null + var unlinkError: Throwable? = null + var permissionsError: Throwable? = null + + var links: RustLinkListDto = RustLinkListDto() + var linkResult: RustLinkResultDto = RustLinkResultDto(linked = true) + var permissions: RustPlayerPermissionsDto = RustPlayerPermissionsDto() + + var linksCalls: Int = 0 + var permissionsCalls: Int = 0 + + /** The code the last redemption carried, exactly as the screen sent it. */ + var lastCode: String? = null + + /** The Steam id the last release named. */ + var lastUnlinked: String? = null + + override suspend fun links(): RustLinkListDto { + linksCalls++ + linksError?.let { throw it } + error?.let { throw it } + return links + } + + override suspend fun link(body: RustLinkRequest): RustLinkResultDto { + lastCode = body.code + linkError?.let { throw it } + return linkResult + } + + override suspend fun unlink(steamId: String): RustUnlinkResultDto { + lastUnlinked = steamId + unlinkError?.let { throw it } + return RustUnlinkResultDto(unlinked = true) + } + + override suspend fun permissions(): RustPlayerPermissionsDto { + permissionsCalls++ + permissionsError?.let { throw it } + error?.let { throw it } + return permissions + } + + /** The coin balances (§11.2.2), and a throw of their own: a 404 is an older module. */ + var economics: RustEconomicsDto = RustEconomicsDto() + var economicsError: Throwable? = null + var economicsCalls: Int = 0 + + override suspend fun economics(): RustEconomicsDto { + economicsCalls++ + economicsError?.let { throw it } + error?.let { throw it } + return economics + } + + /** The servers the backpack section offers (§11.3.2). */ + var servers: RustServerListDto = RustServerListDto() + + override suspend fun servers(): RustServerListDto { + error?.let { throw it } + return servers + } + + /** One backpack per `serverId|steamId`; a throw of its own, so a 409 can stand alone. */ + var backpacks: Map = emptyMap() + var backpackError: Throwable? = null + val backpackReads = mutableListOf() + + override suspend fun backpack(serverId: String, steamId: String): RustBackpackDto { + backpackReads += "$serverId|$steamId" + backpackError?.let { throw it } + error?.let { throw it } + return backpacks["$serverId|$steamId"] ?: RustBackpackDto(serverId = serverId, steamId = steamId) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt new file mode 100644 index 0000000..65b03df --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt @@ -0,0 +1,123 @@ +/* + * 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.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +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 map: RustMapDto = RustMapDto() + var mapLive: RustMapLiveDto = RustMapLiveDto() + + /** + * Answers that replace [mapLive] one call at a time, for a test that needs an + * answer to arrive AFTER something else happened. Consumed first to last; + * [mapLive] answers once it is empty. + */ + val mapLiveQueue: ArrayDeque RustMapLiveDto> = ArrayDeque() + + var serversCalls: Int = 0 + var eventCalls: Int = 0 + var leaderboardCalls: Int = 0 + var onlineCalls: Int = 0 + var wipeCalls: Int = 0 + var mapCalls: Int = 0 + var mapLiveCalls: Int = 0 + + /** The `kind` the last feed read carried — null means it sent none at all. */ + var lastKind: String? = null + + /** The `wipe` the last feed read carried; null means all wipes. */ + var lastFeedWipe: String? = null + var lastLeaderboardWipe: String? = null + var lastSort: String? = null + var lastId: String? = null + + private fun reply(value: T): T { + error?.let { throw it } + return value + } + + override suspend fun getServers(): RustServerListDto { + serversCalls++ + return reply(servers) + } + + override suspend fun getServer(id: String): RustServerResponse { + lastId = id + return reply(server) + } + + override suspend fun getEvents(id: String, kind: String?, wipe: String?, limit: Int?): RustEventListDto { + eventCalls++ + lastId = id + lastKind = kind + lastFeedWipe = wipe + return reply(events) + } + + override suspend fun getLeaderboard( + id: String, + wipe: String?, + sort: String?, + limit: Int?, + ): RustLeaderboardDto { + leaderboardCalls++ + lastId = id + lastLeaderboardWipe = wipe + lastSort = sort + return reply(leaderboard) + } + + override suspend fun getWipes(id: String): RustWipeListDto { + wipeCalls++ + lastId = id + return reply(wipes) + } + + override suspend fun getOnline(id: String): RustOnlineDto { + onlineCalls++ + lastId = id + return reply(online) + } + + override suspend fun getMap(id: String): RustMapDto { + mapCalls++ + lastId = id + return reply(map) + } + + override suspend fun getMapLive(id: String): RustMapLiveDto { + mapLiveCalls++ + lastId = id + error?.let { throw it } + return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt index 4e59f44..daa014b 100644 --- a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt @@ -70,7 +70,7 @@ class AccountTrustedDevicesTest { override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto() override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto() override suspend fun identities(): List = emptyList() - override suspend fun unlinkIdentity(provider: String) = Unit + override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit } private fun repo(api: MeApi) = AccountRepository(api, json) diff --git a/app/src/test/java/com/runicgateway/app/data/repository/LinkedAccountRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/LinkedAccountRepositoryTest.kt new file mode 100644 index 0000000..9262d37 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/LinkedAccountRepositoryTest.kt @@ -0,0 +1,91 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.data.api.dto.InstalledModuleDto +import com.runicgateway.app.data.api.dto.ModulesDto +import com.runicgateway.app.data.api.dto.RustLinkDto +import com.runicgateway.app.data.api.dto.RustLinkListDto +import com.runicgateway.app.data.api.dto.ShardLinkDto +import com.runicgateway.app.data.api.fake.FakePlayerRustApi +import com.runicgateway.app.data.api.fake.FakePlayerShardApi +import com.runicgateway.app.data.api.fake.FakePublicApi +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "Has this user linked a game account" is asked of the site's own module + * (`docs/modules/rust/PLAN.md` D69). + * + * Before phase 11 it was always `module-uo`'s `/player/shard/accounts`. A Rust + * site has no such route, so the question always answered no, and the raid + * alert's row was locked in every channel. + */ +class LinkedAccountRepositoryTest { + + private val publicApi = FakePublicApi() + private val capabilities = SiteCapabilitiesRepository(publicApi) + private val rustApi = FakePlayerRustApi() + private val shardApi = FakePlayerShardApi() + private val repository = LinkedAccountRepository( + capabilities, + PlayerRustRepository(rustApi), + PlayerShardRepository(shardApi), + ) + + private suspend fun running(module: String, vararg caps: String) { + publicApi.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = module, capabilities = caps.toList()))) + capabilities.refresh() + } + + @Test fun aRustSiteAsksTheRustModule() = runTest { + running("rust", "rust", "servers", "identity") + rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "76561198000000001"))) + // The UO read would say no, and it must not be the one that is asked. + shardApi.error = httpError(404) + + assertTrue(repository.hasLinkedAccount()) + assertEquals(1, rustApi.linksCalls) + } + + @Test fun aRustSiteWithNoLinkSaysNo() = runTest { + running("rust", "rust") + assertFalse(repository.hasLinkedAccount()) + } + + @Test fun aUoSiteStillAsksTheShardModuleAndNeverTheRustOne() = runTest { + running("uo", "shard", "atlas") + shardApi.accounts = listOf(ShardLinkDto(account = "walker")) + + assertTrue(repository.hasLinkedAccount()) + assertEquals(0, rustApi.linksCalls) + } + + @Test fun aSiteWithNeitherModuleHasNothingToAsk() = runTest { + running("other", "something") + rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1"))) + + assertFalse(repository.hasLinkedAccount()) + assertEquals(0, rustApi.linksCalls) + } + + @Test fun aHostThatHasNeverAnsweredAsksBothAndAFailureIsNoLink() = runTest { + // One site runs one module, so one of the two 404s; that is not an error. + shardApi.error = httpError(404) + rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1"))) + + assertTrue(repository.hasLinkedAccount()) + } + + @Test fun theSourcesAreReadOffTheCapabilities() { + assertEquals(LinkSource.entries, linkSourcesFor(null)) + assertEquals(listOf(LinkSource.RUST), linkSourcesFor(SiteCapabilities(emptySet(), setOf("rust")))) + assertEquals(listOf(LinkSource.SHARD), linkSourcesFor(SiteCapabilities(emptySet(), setOf("shard")))) + assertEquals(emptyList(), linkSourcesFor(SiteCapabilities(setOf("events"), emptySet()))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/PollingTest.kt b/app/src/test/java/com/runicgateway/app/ui/PollingTest.kt new file mode 100644 index 0000000..290fb4a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/PollingTest.kt @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui + +import com.runicgateway.app.core.result.ApiResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rule a poll lives on (M14, `docs/modules/rust/PLAN.md` D14). + * + * **A refresh is invisible when it succeeds and keeps the rows when it fails.** + * The site's whole premise is that it renders while the game is off, and an app + * that blanked itself the first time a request failed would break that one tier + * along from where it was built. + */ +class PollingTest { + + @Test + fun `a successful refresh replaces the data and clears a previous failure`() { + val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(listOf("new"))) + + assertEquals(UiState.Success(listOf("new")), after.state) + assertFalse(after.refreshFailed) + } + + @Test + fun `a failed refresh keeps the rows and reports the failure`() { + // The case this whole file exists for. Nothing is blanked, nothing is + // retried on the reader's behalf, and the failure is a fact the screen can + // render beside rows that are still the best answer anybody has. + val before = UiState.Success(listOf("old")) + val after = refreshInto(before, ApiResult.NetworkError(RuntimeException("offline"))) + + assertEquals(before, after.state) + assertTrue(after.refreshFailed) + } + + @Test + fun `a failure with nothing on screen is an ordinary error`() { + // There is nothing to protect, so this is a first load that failed — and a + // screen that reported "could not refresh" over a blank page would be + // hiding the retry the reader needs. + val after = refreshInto(UiState.Loading, ApiResult.HttpError(500, "boom")) + + assertTrue(after.state is UiState.Error) + assertFalse(after.refreshFailed) + } + + @Test + fun `a 404 on a first load keeps its own kind`() { + val after = refreshInto(UiState.Loading, ApiResult.HttpError(404, "gone")) + + assertEquals(UiState.Error(ErrorKind.NOT_FOUND, 404), after.state) + } + + @Test + fun `a successful refresh over an error recovers`() { + val after = refreshInto( + UiState.Error(ErrorKind.NETWORK), + ApiResult.Ok(listOf("back")), + ) + + assertEquals(UiState.Success(listOf("back")), after.state) + assertFalse(after.refreshFailed) + } + + @Test + fun `an empty answer is a success, not a failure to keep the old rows through`() { + // An empty list is an ANSWER — the feed filtered to a wipe nothing happened + // in, a fleet with nobody on it. Treating it as "nothing came back" and + // keeping stale rows would make an emptied board impossible to observe. + val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(emptyList())) + + assertEquals(UiState.Success(emptyList()), after.state) + assertFalse(after.refreshFailed) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt b/app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt new file mode 100644 index 0000000..3f35fe5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import com.runicgateway.app.data.api.dto.EventProgressDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A live event's progress lines (MODULE_API 1.12.0, runicnpc D304): a count or a + * percent with a label, and nothing else renders. + */ +class EventProgressTest { + + private fun text(line: EventProgressDto) = progressText( + line, + count = { label, left, of -> "$label: $left of $of left" }, + percent = { label, p -> "$label: $p%" }, + ) + + @Test + fun `a count and a percent read as the web reads them`() { + assertEquals("Bandits: 3 of 8 left", text(EventProgressDto("Bandits", left = 3, of = 8))) + assertEquals("The Juggernaut: 62%", text(EventProgressDto("The Juggernaut", percent = 62))) + } + + @Test + fun `no label, or neither shape, renders nothing`() { + assertNull(text(EventProgressDto("", left = 1, of = 2))) + assertNull(text(EventProgressDto("Odd"))) + assertNull(text(EventProgressDto("Half", left = 1))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt index aa1e757..6e42726 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt @@ -123,13 +123,27 @@ class MenuCapabilityGatingTest { // rather than by the visibility framework. They rendered on a backend with // no module installed and answered "This content couldn't be found", // through a green suite. + // + // **It asks which module, not merely whether one.** M15 put a second + // game's self-service row under `player/`, and a row that declared the + // wrong module's capability would render on a site running the other + // game and answer 404 — the same failure in a new place. val onAModulePath = APP_MENU.filter { it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS } - assertEquals(8, onAModulePath.size) - assertTrue( - onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(), - onAModulePath.all { it.capability == Capability.SHARD }, + assertEquals(9, onAModulePath.size) + + val expected = onAModulePath.associate { entry -> + entry.route to if (entry.route.startsWith("player/rust")) { + Capability.RUST + } else { + Capability.SHARD + } + } + + assertEquals( + expected, + onAModulePath.associate { it.route to it.capability }, ) } @@ -154,6 +168,26 @@ class MenuCapabilityGatingTest { } } + @Test fun moderationAndSupportNeedTheShardModule() { + // Both call `/admin/shard/*`, module-uo's admin mount. Before M20 they + // carried no capability, so a moderator on a Rust-only site saw a UO form + // whose every action failed. + val staffRows = APP_MENU.filter { it.route == Routes.ADMIN_MODERATION || it.route == Routes.ADMIN_SUPPORT } + assertEquals(2, staffRows.size) + assertTrue(staffRows.all { it.capability == Capability.SHARD }) + + val moderator = signedIn(Role.MODERATOR) + val rustOnly = visibleEntries(APP_MENU, moderator, null, serving(Capability.RUST)).map { it.route } + assertFalse(Routes.ADMIN_MODERATION in rustOnly) + assertFalse(Routes.ADMIN_SUPPORT in rustOnly) + // …while the core staff rows stay. + assertTrue(Routes.ADMIN_DASHBOARD in rustOnly) + + val uo = visibleEntries(APP_MENU, moderator, null, serving(Capability.SHARD)).map { it.route } + assertTrue(Routes.ADMIN_MODERATION in uo) + assertTrue(Routes.ADMIN_SUPPORT in uo) + } + @Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() { // Events are core's. A `feature` on one of them would gate a core screen on // a module's visibility config, which is the coupling this separation exists diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt index ace878d..d646a51 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt @@ -52,11 +52,17 @@ class NavOverridesTest { * showing through** (M13): About is core's last nav row at index 7 and the * module's nine append after it at 8-16. Under the stale sixteen-row table * About was index 15 and came last, which is what these assertions used to say. + * + * **The Rust row is last, at 17** (M14). These tests carry no capability + * answer, which fails open, so both game modules' rows appear here — a state + * no real backend is in and exactly the one this merge has to be correct for, + * since the sort key is the website's number line and not what happens to be + * installed. */ private val mergedPublic = listOf( Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"), Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, - Routes.SHARD_MARKET, + Routes.SHARD_MARKET, Routes.RUST, ) /** How many rows that block holds, so the take/drop below say why. */ @@ -154,13 +160,14 @@ class NavOverridesTest { // ── Order ──────────────────────────────────────────────────────────── @Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() { - // The website's own indices: About is 7, and Market — the last row of all, - // now that the module's nine append after core's eight — is 16. Dragging - // About to the top and Home past the end writes exactly this. + // The website's own indices: About is 7, Market is 16, and the Rust row — + // the last of all, now that a second game module's row appends after the + // first's nine — is 17. Dragging About to the top and Home past the end + // writes exactly this. val routes = routes( nav( "/site/about" to entry(order = 0), - "/" to entry(order = 17), + "/" to entry(order = 18), ), ) @@ -168,7 +175,7 @@ class NavOverridesTest { listOf( Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, - Routes.HOME, + Routes.RUST, Routes.HOME, ), routes.take(publicBlock), ) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt index 37c0d3e..c0d1218 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt @@ -19,12 +19,12 @@ import org.junit.Test class NavPathsTest { @Test fun everyWebsiteNavPathIsMapped() { - // Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If - // either side adds one, this is the test that says so — a path with no - // mapping is silently unresolvable in phase 6's link handling, which is - // exactly how the nine shard rows went stale for a month after the - // module-system cutover moved them from /site/ to /uo/ (M13). - assertEquals(17, WEBSITE_PUBLIC_NAV.size) + // Core's eight rows, module-uo's nine and module-rust's one, all quoted in + // NavPaths.kt. If any of the three adds one, this is the test that says so + // — a path with no mapping is silently unresolvable in phase 6's link + // handling, which is exactly how the nine shard rows went stale for a month + // after the module-system cutover moved them from /site/ to /uo/ (M13). + assertEquals(18, WEBSITE_PUBLIC_NAV.size) assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size) } @@ -61,9 +61,15 @@ class NavPathsTest { } @Test fun theDrawerRowsAreTheIntersectionWithAppMenu() { - // Ten of the seventeen have a drawer row. The other seven are mapped but not - // surfaced — three news category tabs and the four Shard hub boards — and - // an override for one of them is ignored rather than obeyed (§6.2). + // Eleven of the eighteen have a drawer row. The other seven are mapped but + // not surfaced — three news category tabs and the four Shard hub boards — + // and an override for one of them is ignored rather than obeyed (§6.2). + // + // **Both game modules appear here, and no backend serves both lists.** The + // table is a superset on purpose: a path for a module an operator has not + // installed never appears in that backend's nav and is never looked up, + // while a path MISSING from it makes a link that does exist hand off to a + // browser. val coded = APP_MENU.map { it.route }.toSet() val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path } @@ -71,6 +77,7 @@ class NavPathsTest { listOf( "/", "/site/news", "/site/events", "/wiki", "/site/about", "/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market", + "/rust", ), surfaced, ) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt index b60465c..ea14c76 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt @@ -166,10 +166,12 @@ class NavTreeTest { val shape = tree(row).shape() - // Nine public rows are left at the top level (Wiki moved into the section), - // then the section, then the app's own rows. - assertEquals("section:lore", shape[9]) - assertEquals(Routes.CONTACT, shape[10]) + // Ten public rows are left at the top level (Wiki moved into the section), + // then the section, then the app's own rows. The tenth is the Rust row: + // these tests carry no capability answer, which fails open, so both game + // modules' rows are present — see NavOverridesTest's `mergedPublic`. + assertEquals("section:lore", shape[10]) + assertEquals(Routes.CONTACT, shape[11]) } @Test fun aSectionsOrderPlacesItAmongTheCodedRows() { @@ -324,9 +326,9 @@ class NavTreeTest { val shape = tree(row).shape() assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") }) - // Market, not About: the module's nine rows append after core's eight on the - // website's number line, so Market is the last coded row rather than About. - assertEquals(Routes.SHARD_MARKET, shape[shape.indexOf("link:a") - 1]) + // The Rust row, not About: both modules' rows append after core's eight on + // the website's number line, and Rust's is last at 17. + assertEquals(Routes.RUST, shape[shape.indexOf("link:a") - 1]) } @Test fun aLinksOrderPlacesItAmongTheCodedRows() { diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt new file mode 100644 index 0000000..2dba399 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/RustNavigationTest.kt @@ -0,0 +1,186 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Role +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionUser +import com.runicgateway.app.data.repository.Capability +import com.runicgateway.app.data.repository.SiteCapabilities +import com.runicgateway.app.ui.rust.RustTab +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Rust row, its routes, and the two ways a Rust link can reach the app + * (M14, `docs/modules/rust/PLAN.md` §18). + * + * The question this file really asks is the phase criterion's other half: **a UO + * site is unchanged.** Two game modules can be installed on one backend, and + * neither one's rows may appear on a site running only the other. + */ +class RustNavigationTest { + + private fun serving(vararg caps: String) = + SiteCapabilities(core = emptySet(), modules = caps.toSet()) + + private fun routesFor(capabilities: SiteCapabilities?) = + visibleEntries(APP_MENU, Session.SignedOut, features = null, capabilities = capabilities) + .map { it.route } + + @Test fun aRustSiteShowsTheRustRowAndNoShardRows() { + val routes = routesFor(serving(Capability.RUST)) + + assertTrue(Routes.RUST in routes) + assertFalse("a Rust site has no shard", Routes.SHARD in routes) + assertFalse(Routes.ATLAS in routes) + } + + @Test fun aUoSiteIsExactlyAsItWas() { + // The other half of the criterion. Adding a second game module must not + // put a row on a site that does not run it. + val routes = routesFor(serving(Capability.SHARD)) + + assertFalse("a UO site has no Rust row", Routes.RUST in routes) + assertTrue(Routes.SHARD in routes) + } + + @Test fun bothModulesInstalledShowsBothTrees() { + val routes = routesFor(serving(Capability.SHARD, Capability.RUST)) + + assertTrue(Routes.SHARD in routes) + assertTrue(Routes.RUST in routes) + } + + @Test fun theSurfaceCapabilitiesDoNotRevealTheRow() { + // `module-rust` declares `servers`, `killfeed`, `leaderboard`, `presence` + // and `wipes` as well, and the app gates on NONE of them — every one names + // a surface, and core flattens all modules' capabilities into one list, so + // another module declaring `servers` would otherwise reveal these screens + // on a site with no Rust at all. + val routes = routesFor(serving("servers", "killfeed", "leaderboard", "presence", "wipes")) + + assertFalse(Routes.RUST in routes) + } + + @Test fun aHostThatHasNeverAnsweredStillShowsEverything() { + // Fail-open on an UNKNOWN answer, which is not the same as an empty one. + // The server gates every call regardless, so the cost of guessing wrong is + // a link that briefly 404s. + assertTrue(Routes.RUST in routesFor(null)) + } + + @Test fun aServerIdIsEncodedIntoItsRoute() { + assertEquals("rust/servers/main", Routes.rustServer("main")) + assertEquals("rust/servers/eu-main", Routes.rustServer("eu-main")) + // An operator names these, and nothing stops one carrying a character a + // path would otherwise eat. + assertEquals("rust/servers/a%2Fb", Routes.rustServer("a/b")) + assertEquals("rust/servers/two%20words", Routes.rustServer("two words")) + } + + @Test fun theWebsiteNavRowOpensNatively() { + // `module-rust` registers exactly one nav item, `{ label: 'Servers', to: + // '/rust' }`. Without this mapping an admin's nav override on that row — + // or an added link to it — hands off to a browser instead. + assertEquals(Routes.RUST, appRouteForWebPath("/rust")) + assertEquals(Routes.RUST, appRouteForWebPath("/rust/")) + } + + @Test fun anAddedLinkToOneServerResolves() { + assertEquals(Routes.rustServer("main"), resolveWebPath("/rust/servers/main")) + } + + @Test fun theRustPrefixIsNotMistakenForACmsPage() { + // The site serves CMS pages from a top-level `/`, and `/rust` would + // otherwise fall into that rule and open a page-not-found screen. It is + // reserved so the nav table above answers it — and so that a site WITHOUT + // the module hands off to the browser, which gives the same answer the web + // would. + assertEquals(Routes.RUST, resolveWebPath("/rust")) + assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra")) + } + + // ── The player's own row (M15) ──────────────────────────────────────── + + private fun player() = signedIn(Role.PLAYER) + + private fun staff() = signedIn(Role.ADMIN) + + private fun signedIn(role: Role) = + Session.SignedIn(SessionUser(id = 1, username = "u", role = role)) + + private fun routesFor(session: Session, capabilities: SiteCapabilities?) = + visibleEntries(APP_MENU, session, features = null, capabilities = capabilities) + .map { it.route } + + @Test fun theAccountRowNeedsBothASessionAndTheModule() { + // Signed out, the row is not there whatever the backend serves: every + // route behind it is `requireAuth`. + assertFalse(Routes.PLAYER_RUST in routesFor(serving(Capability.RUST))) + + assertTrue(Routes.PLAYER_RUST in routesFor(player(), serving(Capability.RUST))) + assertFalse( + "a UO-only site has no Rust account row", + Routes.PLAYER_RUST in routesFor(player(), serving(Capability.SHARD)), + ) + } + + @Test fun staffSeeTheAccountRowToo() { + // `PLAYER` access is `isPlayer || isStaff` — staff play the game as well, + // and `/player/rust/*` is `requireAuth` with no role above it. + assertTrue(Routes.PLAYER_RUST in routesFor(staff(), serving(Capability.RUST))) + } + + @Test fun theAccountRowHangsOnTheModuleNotOnASurfaceWord() { + // `identity` is one of the module's surface words, like `servers` above. + // A capability answers *is the module there*, and only `rust` does. + assertFalse(Routes.PLAYER_RUST in routesFor(player(), serving("identity"))) + } + + @Test fun aRustPathWithAQueryHandsOff() { + // The website keeps tab, filter, wipe and sort in the URL; the app keeps + // them in a view model. Resolving `?tab=wipes` natively would silently drop + // 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")) + } + + // ── The notification links (phase 11, D71) ──────────────────────────── + + @Test fun theNewLeaderNoticeOpensTheLeaderboardTab() { + // `leaderboardPath` in module-rust's triggers.js. + assertEquals("rust/servers/main?tab=leaderboard", resolveWebPath("/rust/servers/main?tab=leaderboard")) + assertEquals(Routes.rustServer("main", RustTab.LEADERBOARD), resolveWebPath("/rust/servers/main?tab=leaderboard")) + assertEquals(Routes.rustServer("main", RustTab.WIPES), resolveWebPath("/rust/servers/main?tab=wipes")) + } + + @Test fun onlyATabTheAppHasResolves() { + // The website has a Clans tab and the app does not: the browser, not the feed. + assertNull(resolveWebPath("/rust/servers/main?tab=clans")) + assertNull(resolveWebPath("/rust/servers/main?tab=")) + assertNull(resolveWebPath("/rust/servers/main?tab=wipes&wipe=w1")) + assertNull(resolveWebPath("/rust/servers/main?sort=kills")) + assertNull(resolveWebPath("/rust/servers/main?tab=LEADERBOARD")) + } + + @Test fun aTablessRouteStaysTheOneEveryOtherCallerBuilds() { + assertEquals("rust/servers/main", Routes.rustServer("main")) + assertEquals(RustTab.ONLINE, RustTab.fromWire("online")) + assertNull(RustTab.fromWire("clans")) + assertNull(RustTab.fromWire(null)) + } + + @Test fun theAccountLinkedNoticeOpensThePlayersOwnScreen() { + // `PATHS.account` in module-rust's triggers.js. M15 deferred this to the + // phase that would need it. + assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust")) + assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust/")) + assertNull(resolveWebPath("/player/rust?x=1")) + assertNull(resolveWebPath("/player/shard")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt index 269425a..8a0368e 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt @@ -14,7 +14,8 @@ import org.junit.Test /** * Tests the pure notification helpers: the stream → deep-link route map (PLAN.md * §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it, - * and the personal-item gating (a personal id needs a linked game account). + * and the personal-item gating (a personal id needs a linked game account to + * switch push on, and nothing else). */ class NotificationRoutingTest { @@ -33,15 +34,23 @@ class NotificationRoutingTest { assertEquals(Routes.HOME, Routes.forStream("something.new")) } - @Test fun personalItemNeedsLinkedAccount() { - val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true) - assertFalse(itemSelectable(personal, hasLinkedAccount = false)) - assertTrue(itemSelectable(personal, hasLinkedAccount = true)) + @Test fun personalItemNeedsALinkOnlyToSwitchPushOn() { + val personal = NotificationChannelItemDto( + id = "vendor.sale", personal = true, requiresLinkedAccount = true, + channels = listOf("push", "inapp", "email"), + ) + assertTrue(pushNeedsLink(personal, hasLinkedAccount = false)) + assertFalse(pushNeedsLink(personal, hasLinkedAccount = true)) + assertFalse(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = false)) + assertTrue(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = true)) } - @Test fun generalItemIsAlwaysSelectable() { - val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false) - assertTrue(itemSelectable(general, hasLinkedAccount = false)) + @Test fun generalItemIsNeverHeldBack() { + val general = NotificationChannelItemDto( + id = "news.post", personal = false, requiresLinkedAccount = false, channels = listOf("push"), + ) + assertFalse(pushNeedsLink(general, hasLinkedAccount = false)) + assertTrue(canSetMode(general, CHANNEL_PUSH, "instant", hasLinkedAccount = false)) } // ── The tickle → destination map (ENGAGEMENT.md phase 8) ─────────────── diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt index b0dcd20..b212983 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt @@ -64,10 +64,36 @@ class NotificationSettingsViewModelTest { assertEquals(listOf("off", "instant"), push.modes) } - @Test fun personalItemsStillNeedALinkedAccount() { + @Test fun aTriggerOnlyPersonalItemIsNotHeldBackAtAll() { + // Nothing can push it, so there is no push control for the link to hold, + // and the hint would describe a control that is not on the screen. val personal = prefs().items.first { it.personal } - assertEquals(false, itemSelectable(personal, hasLinkedAccount = false)) - assertEquals(true, itemSelectable(personal, hasLinkedAccount = true)) + assertEquals(false, pushNeedsLink(personal, hasLinkedAccount = false)) + assertEquals(true, canSetMode(personal, "email", "instant", hasLinkedAccount = false)) + } + + // ── Rust D69: a Rust site's raid alert ───────────────────────────────── + + private val raid = NotificationChannelItemDto( + id = "rust.base.destroyed", label = "Your base was raided", + personal = true, requiresLinkedAccount = true, + channels = listOf("push", "inapp", "email"), + modes = mapOf("push" to "instant", "inapp" to "instant", "email" to "off"), + ) + + @Test fun withALinkPushCanBeSwitchedOn() { + assertEquals(true, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = true)) + } + + @Test fun withoutALinkEveryChannelCanStillBeSwitchedOff() { + // Phase 10's criterion ends "and can be switched off there". Before phase + // 11 the whole row was disabled whenever the link check failed, and it + // always failed on a Rust site. + listOf(CHANNEL_PUSH, "inapp", "email").forEach { channel -> + assertEquals(channel, true, canSetMode(raid, channel, MODE_OFF, hasLinkedAccount = false)) + } + assertEquals(true, canSetMode(raid, "inapp", "instant", hasLinkedAccount = false)) + assertEquals(false, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = false)) } @Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking { diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt new file mode 100644 index 0000000..a7b2047 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt @@ -0,0 +1,110 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventCalendarEntryDto +import com.runicgateway.app.data.api.fake.FakeEventsApi +import com.runicgateway.app.data.repository.EventsRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException + +/** Which event a run on the map belongs to (D123, D125). */ +class EventRunResolverTest { + + private val api = FakeEventsApi() + private var clock = 1_000_000L + private val resolver = EventRunResolver(EventsRepository(api), now = { clock }) + + private fun run(id: Long?, slug: String) = EventCalendarEntryDto(kind = "run", runId = id, slug = slug) + + @Test + fun `a listed run resolves to its event, matched as text against the plugin's string`() = runTest { + // Core sends 41; the plugin sends "41". They are the same run. + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + assertEquals(mapOf("41" to "harbor-brawl"), resolver.resolve(setOf("41"))) + } + + @Test + fun `a run the public calendar does not list stays unlinked`() = runTest { + // A rehearsal or an unlisted event is absent from the calendar in SQL, so + // absence is the whole gate: there is no second rule here. + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + assertEquals(emptyMap(), resolver.resolve(setOf("99"))) + } + + @Test + fun `a projection and a core older than D125 name no run`() = runTest { + api.calendar = EventCalendarDto( + listOf( + EventCalendarEntryDto(kind = "projected", slug = "weekly"), + run(null, "old-core"), + ), + ) + assertEquals(emptyMap(), resolver.resolve(setOf("41"))) + } + + @Test + fun `known ids never ask again`() = runTest { + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + repeat(5) { resolver.resolve(setOf("41")) } + clock += 10 * EventRunResolver.REFRESH_MS + resolver.resolve(setOf("41")) + assertEquals(1, api.calendarCalls) + } + + @Test + fun `an unknown id re-reads at most once a minute`() = runTest { + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + resolver.resolve(setOf("41", "42")) + assertEquals(1, api.calendarCalls) + + // Every ten-second poll inside the minute: no new read. + repeat(5) { + clock += 10_000 + resolver.resolve(setOf("41", "42")) + } + assertEquals(1, api.calendarCalls) + + // Past the minute, the new run is found. + clock += 10_000 + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"), run(42, "crate-rush"))) + assertEquals(mapOf("41" to "harbor-brawl", "42" to "crate-rush"), resolver.resolve(setOf("41", "42"))) + assertEquals(2, api.calendarCalls) + } + + @Test + fun `the same read names each run's event, and titles never asks the calendar itself`() = runTest { + api.calendar = EventCalendarDto( + listOf( + EventCalendarEntryDto(kind = "run", runId = 41, slug = "harbour-raid", title = "Harbour Raid"), + EventCalendarEntryDto(kind = "run", runId = 42, slug = "untitled", title = ""), + ), + ) + assertEquals(emptyMap(), resolver.titles(setOf("41"))) + assertEquals(0, api.calendarCalls) + + resolver.resolve(setOf("41", "42")) + assertEquals(mapOf("41" to "Harbour Raid"), resolver.titles(setOf("41", "42", "99"))) + assertEquals(1, api.calendarCalls) + } + + @Test + fun `a failed read keeps what the last one found, and still counts as a read`() = runTest { + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + resolver.resolve(setOf("41")) + + api.error = IOException("offline") + clock += EventRunResolver.REFRESH_MS + assertEquals(mapOf("41" to "harbor-brawl"), resolver.resolve(setOf("41", "42"))) + assertEquals(2, api.calendarCalls) + + // Not asked again on the very next poll. + clock += 10_000 + resolver.resolve(setOf("41", "42")) + assertEquals(2, api.calendarCalls) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt new file mode 100644 index 0000000..6564427 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt @@ -0,0 +1,213 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121). + * + * **These are the web's cases** (`module-rust/client/test/mapGeometry.test.js`), + * so the phone and the page cannot put the same crate in two squares. The grid + * cases are the GAME's answers: on 2026-09-25 a probe on the Oxide rig (a 3000 + * map, seed 1234) asked `MapHelper.PositionToString` for these positions and + * wrote down what it said. + * + * One difference from the web, and it is deliberate: a canvas grows DOWN, so + * [MapFrame.toPixel] answers y measured from the top, where Leaflet's frame + * measures it up from the bottom. + */ +class MapFrameTest { + + /** The rig's map, as `GET /map` describes it. */ + private val rig = MapFrame( + RustMapGeometryDto( + worldSize = 3000.0, + oceanMargin = 500.0, + width = 2500.0, + height = 2500.0, + gridCells = 20, + gridCellSize = 150.0, + ), + ) + + @Test + fun `the grid label is the game's, at every probed position`() { + val probed = listOf( + Triple("ue_jungle_swamp_a", 764.7 to 167.4, "P8"), + Triple("ue_jungle_swamp_a", 733.7 to -556.0, "O13"), + Triple("harbor_2", 1122.7 to 204.6, "R8"), + Triple("harbor_1", 678.1 to 1005.7, "O3"), + Triple("ferry_terminal_1", 645.4 to -1004.1, "O16"), + Triple("fishing_village_a", -787.8 to 224.1, "E8"), + Triple("fishing_village_c", -203.0 to -911.1, "I16"), + Triple("fishing_village_b", 1142.1 to -566.5, "R13"), + Triple("desert_military_base_c", 94.0 to -729.3, "K14"), + Triple("arctic_research_base_a", -556.9 to 840.0, "G4"), + Triple("powerplant_1", -608.6 to -346.4, "F12"), + Triple("water_treatment_plant_1", 497.8 to 84.9, "N9"), + Triple("nw-corner", -1499.0 to 1499.0, "A0"), + Triple("se-corner", 1499.0 to -1499.0, "T19"), + Triple("origin", 0.0 to 0.0, "K10"), + ) + for ((name, at, game) in probed) assertEquals(name, game, rig.gridLabel(at.first, at.second)) + } + + @Test + fun `a 146 point 3 m cell, phase 3's constant, would have disagreed with the game`() { + val wrong = MapFrame(rig.geometry.copy(gridCells = 21, gridCellSize = 146.3)) + assertNotEquals("O16", wrong.gridLabel(645.4, -1004.1)) + } + + @Test + fun `columns past Z are spelled the way Rust spells them`() { + assertEquals("A", MapFrame.column(0)) + assertEquals("Z", MapFrame.column(25)) + assertEquals("AA", MapFrame.column(26)) + assertEquals("AB", MapFrame.column(27)) + } + + @Test + fun `the ocean margin is in pixels and is not scaled`() { + assertEquals(0.5, rig.scale, 0.0) + // The world's corners sit exactly one margin inside the picture's; the + // south-west corner is at the BOTTOM left of a canvas. + assertEquals(PicturePoint(500.0, 2000.0), rig.toPixel(-1500.0, -1500.0)) + assertEquals(PicturePoint(2000.0, 500.0), rig.toPixel(1500.0, 1500.0)) + assertEquals(PicturePoint(1250.0, 1250.0), rig.toPixel(0.0, 0.0)) + } + + @Test + fun `north is up, so a larger z is higher on the screen, and x is across`() { + val low = rig.toPixel(100.0, 100.0) + val high = rig.toPixel(100.0, 400.0) + assertTrue(high.y < low.y) + assertEquals(low.x, high.x, 0.0) + } + + @Test + fun `something off the edge of the world is still placed, outside the picture`() { + // The rig's cargo ship, as the probe found it: past the world AND the margin. + val p = rig.toPixel(2691.6, -1453.1) + assertTrue(p.x > rig.width) + assertTrue(p.y > 0 && p.y < rig.height) + } + + @Test + fun `the app cannot assume the rig's size`() { + // A 4500 m world is a 3250 px picture (§31.1), and its origin is its centre. + val big = MapFrame( + RustMapGeometryDto(worldSize = 4500.0, oceanMargin = 500.0, width = 3250.0, height = 3250.0), + ) + assertEquals(PicturePoint(1625.0, 1625.0), big.toPixel(0.0, 0.0)) + assertEquals(PicturePoint(500.0, 2750.0), big.toPixel(-2250.0, -2250.0)) + } + + @Test + fun `the grid has a line per edge and a label per cell, A0 at the north-west corner`() { + val g = rig.grid() + assertEquals(2 * (20 + 1), g.lines.size) + assertEquals(20 * 20, g.labels.size) + val a0 = g.labels.first { it.text == "A0" } + assertEquals(-1500.0, a0.x, 0.0) + assertEquals(1500.0, a0.z, 0.0) + assertEquals(MapGrid(emptyList(), emptyList()), MapFrame(RustMapGeometryDto()).grid()) + } + + @Test + fun `a geometry that cannot place anything places nothing rather than NaN everywhere`() { + val none = MapFrame(RustMapGeometryDto(worldSize = 0.0, width = 2500.0)) + assertEquals(0.0, none.scale, 0.0) + assertFalse(none.canPlace) + assertNull(MapFrame(RustMapGeometryDto(worldSize = 3000.0)).gridLabel(0.0, 0.0)) + } + + @Test + fun `a hack timer reads as minutes and seconds`() { + assertEquals("9:00", countdown(540)) + assertEquals("1:01", countdown(61.4)) + assertEquals("0:00", countdown(-3)) + assertEquals("0:00", countdown(null)) + } + + @Test + fun `the picture's path is joined under api v1 as a relative path`() { + // Relative, like every Retrofit path here, so a site under a prefix keeps it. + assertEquals( + "api/v1/public/rust/servers/main/map/image?v=28da6e8a", + mapPictureUrl("/public/rust/servers/main/map/image?v=28da6e8a"), + ) + } + + // ── The screen transform ─────────────────────────────────────────────── + + private val phone = ViewSize(1080f, 1500f) + private val picture = ViewSize(2500f, 2500f) + + @Test + fun `fit shows the whole picture, centred`() { + val t = MapTransform.fit(phone, picture) + assertEquals(1080f / 2500f, t.scale, 1e-6f) + assertEquals(0f, t.offsetX, 1e-3f) + assertEquals((1500f - 1080f) / 2, t.offsetY, 1e-3f) + } + + @Test + fun `zoom stops at fit and at four times the picture's own pixels`() { + val fit = MapTransform.fit(phone, picture) + val tooFar = fit.transformed(phone, picture, 540f, 750f, 0f, 0f, 1000f) + assertEquals(MapTransform.MAX_ZOOM, tooFar.scale, 0f) + val tooNear = tooFar.transformed(phone, picture, 540f, 750f, 0f, 0f, 0.0001f) + assertEquals(fit.scale, tooNear.scale, 1e-6f) + } + + @Test + fun `a pinch keeps the picture pixel under the fingers where it was`() { + val fit = MapTransform.fit(phone, picture) + val px = fit.pictureX(300f) + val py = fit.pictureY(900f) + val zoomed = fit.transformed(phone, picture, 300f, 900f, 0f, 0f, 2f) + assertEquals(px, zoomed.pictureX(300f), 1e-3) + assertEquals(py, zoomed.pictureY(900f), 1e-3) + } + + @Test + fun `a pan cannot lose the map, but may follow something a little past its edge`() { + val zoomed = MapTransform.fit(phone, picture).transformed(phone, picture, 540f, 750f, 0f, 0f, 4f) + val flung = zoomed.transformed(phone, picture, 540f, 750f, -1e7f, -1e7f, 1f) + // The view's centre stays within the picture plus a quarter of it. + assertEquals(2500.0 * 1.25, flung.pictureX(540f), 1e-2) + assertEquals(2500.0 * 1.25, flung.pictureY(750f), 1e-2) + } + + @Test + fun `a box that grows keeps the reader's zoom and the place they were looking at`() { + // The walk: the status line under the map went from two lines to one, the + // box grew, and the zoom was thrown away. + val zoomed = MapTransform.fit(phone, picture).transformed(phone, picture, 300f, 900f, 0f, 0f, 4f) + val taller = ViewSize(1080f, 1560f) + val centreBefore = zoomed.pictureX(540f) to zoomed.pictureY(750f) + + val after = zoomed.resized(phone, taller, picture) + + assertEquals(zoomed.scale, after.scale, 0f) + assertEquals(centreBefore.first, after.pictureX(540f), 1e-2) + assertEquals(centreBefore.second, after.pictureY(780f), 1e-2) + } + + @Test + fun `a box that grows past the old zoom's floor is clamped up, not refitted from scratch`() { + // Fitted to a small box, the scale is below what a larger box allows. + val small = ViewSize(540f, 750f) + val fitSmall = MapTransform.fit(small, picture) + val after = fitSmall.resized(small, phone, picture) + assertEquals(MapTransform.minScale(phone, picture), after.scale, 1e-6f) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt new file mode 100644 index 0000000..cbd9479 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt @@ -0,0 +1,162 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustMapBaseDto +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapEventDto +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.data.api.dto.RustMapPlayerDto +import com.runicgateway.app.data.api.dto.RustMapWorldDto +import com.runicgateway.app.data.api.dto.RustMonumentDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** What is drawn and what a tap means (D121, §31.4). */ +class MapMarkersTest { + + private val geometry = RustMapGeometryDto( + worldSize = 3000.0, + oceanMargin = 500.0, + width = 2500.0, + height = 2500.0, + gridCells = 20, + gridCellSize = 150.0, + ) + private val frame = MapFrame(geometry) + private val all = MapLayer.entries.toSet() + + @Test + fun `a layer the server did not send draws nothing, whatever the switches say`() { + // The gate is the server's: an absent layer is null on the wire, and the + // switches being on cannot conjure it. + val live = RustMapLiveDto(live = true, world = listOf(RustMapWorldDto("cargo", 1.0, 2.0))) + val markers = mapMarkers(RustMapDto(geometry = geometry), live, all) + assertEquals(listOf("cargo"), markers.map { it.kind }) + } + + @Test + fun `a switch the reader turned off hides a layer they were sent`() { + val map = RustMapDto(geometry = geometry, monuments = listOf(RustMonumentDto(label = "Harbor", x = 1.0, z = 1.0))) + val live = RustMapLiveDto( + live = true, + world = listOf(RustMapWorldDto("heli", 0.0, 0.0)), + bases = listOf(RustMapBaseDto("tc", 5.0, 5.0)), + ) + val markers = mapMarkers(map, live, all - MapLayer.WORLD) + assertEquals(listOf(MapLayer.BASES), markers.map { it.layer }) + } + + @Test + fun `the viewer's own dot and their mates are drawn last, on top`() { + val live = RustMapLiveDto( + live = true, + players = listOf(RustMapPlayerDto(steamId = "1", name = "A", online = true)), + mates = listOf(RustMapPlayerDto(steamId = "2", self = true, online = true)), + events = listOf(RustMapEventDto(kind = "zone", runId = "41", radius = 60.0)), + ) + assertEquals( + listOf(MapLayer.EVENTS, MapLayer.PLAYERS, MapLayer.MATES), + mapMarkers(RustMapDto(geometry = geometry), live, all).map { it.layer }, + ) + } + + @Test + fun `a player with no name yet is named by the id the layer carried`() { + val live = RustMapLiveDto(players = listOf(RustMapPlayerDto(steamId = "76561198000000000", name = ""))) + assertEquals("76561198000000000", mapMarkers(RustMapDto(), live, all).single().name) + } + + // ── The tap ──────────────────────────────────────────────────────────── + + /** One screen pixel per picture pixel, no offset: screen = picture. */ + private val identity = MapTransform(1f, 0f, 0f) + + private fun at(m: MapMarker) = frame.toPixel(m.x, m.z) + + @Test + fun `a tap within reach picks the nearest point, and one out of reach picks nothing`() { + val near = MapMarker(MapLayer.PLAYERS, "player", 0.0, 0.0, name = "near") + val far = MapMarker(MapLayer.PLAYERS, "player", 60.0, 0.0, name = "far") + val p = at(near) + + assertEquals(near, nearestMarker(listOf(far, near), frame, identity, p.x.toFloat() + 5, p.y.toFloat(), 24f)) + assertNull(nearestMarker(listOf(near), frame, identity, p.x.toFloat() + 40, p.y.toFloat(), 24f)) + } + + @Test + fun `a point inside a zone wins over the zone`() { + // Otherwise a zone drawn round a monument would swallow every tap on it. + val zone = MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41", radiusMetres = 100.0) + val crate = MapMarker(MapLayer.EVENTS, "crate", 10.0, 0.0, runId = "41") + val p = at(crate) + assertEquals(crate, nearestMarker(listOf(zone, crate), frame, identity, p.x.toFloat(), p.y.toFloat(), 24f)) + } + + @Test + fun `a zone answers a tap anywhere on its ground`() { + // 100 m at the rig's 0.5 px per metre is 50 px of ground. + val zone = MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41", radiusMetres = 100.0) + val c = at(zone) + assertEquals(zone, nearestMarker(listOf(zone), frame, identity, c.x.toFloat() + 45, c.y.toFloat(), 24f)) + assertNull(nearestMarker(listOf(zone), frame, identity, c.x.toFloat() + 80, c.y.toFloat(), 24f)) + } + + @Test + fun `among equals the one drawn on top wins`() { + val under = MapMarker(MapLayer.PLAYERS, "player", 0.0, 0.0, name = "under") + val over = MapMarker(MapLayer.MATES, "mate", 0.0, 0.0, self = true) + val p = at(under) + assertEquals(over, nearestMarker(listOf(under, over), frame, identity, p.x.toFloat(), p.y.toFloat(), 24f)) + } + + @Test + fun `the reach is on the screen, so zooming in narrows it on the ground`() { + val a = MapMarker(MapLayer.BASES, "tc", 0.0, 0.0) + val p = at(a) + val zoomed = MapTransform(4f, 0f, 0f) + // 10 picture px away is 40 screen px at 4x: out of a 24 px reach. + val tapX = ((p.x + 10) * 4).toFloat() + assertNull(nearestMarker(listOf(a), frame, zoomed, tapX, (p.y * 4).toFloat(), 24f)) + assertTrue(nearestMarker(listOf(a), frame, identity, (p.x + 10).toFloat(), p.y.toFloat(), 24f) == a) + } + + @Test + fun `the run ids are the events layer's, once each, and never blank`() { + val live = RustMapLiveDto( + events = listOf( + RustMapEventDto(kind = "zone", runId = "41"), + RustMapEventDto(kind = "crate", runId = "41"), + RustMapEventDto(kind = "npc", runId = "42"), + RustMapEventDto(kind = "npc", runId = ""), + ), + ) + assertEquals(setOf("41", "42"), eventRunIds(live)) + assertEquals(emptySet(), eventRunIds(RustMapLiveDto())) + } + + @Test + fun `runicnpc D302 and D303 - an NPC's name, a boss and an add, and a boss outside any event`() { + val live = RustMapLiveDto( + live = true, + world = listOf(RustMapWorldDto(kind = "boss", x = 5.0, z = 5.0, name = "The Warden")), + events = listOf( + RustMapEventDto(kind = "npc", runId = "41", name = "The Juggernaut", boss = true), + RustMapEventDto(kind = "npc", runId = "41", name = "Bandit", add = true), + RustMapEventDto(kind = "npc", runId = "41", name = "Bandit"), + ), + ) + val markers = mapMarkers(RustMapDto(geometry = geometry), live, all) + val world = markers.single { it.layer == MapLayer.WORLD } + assertEquals("The Warden", world.name) + assertTrue(world.boss) + val npcs = markers.filter { it.layer == MapLayer.EVENTS } + assertEquals(listOf(true, false, false), npcs.map { it.boss }) + assertEquals(listOf(false, true, false), npcs.map { it.add }) + assertEquals(listOf("The Juggernaut", "Bandit", "Bandit"), npcs.map { it.name }) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt new file mode 100644 index 0000000..b233e22 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustAccountViewModelTest.kt @@ -0,0 +1,271 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustBalanceDto +import com.runicgateway.app.data.api.dto.RustEconomicsDto +import com.runicgateway.app.data.api.dto.RustEconomicsServerDto +import com.runicgateway.app.data.api.dto.RustLinkDto +import com.runicgateway.app.data.api.dto.RustLinkListDto +import com.runicgateway.app.data.api.dto.RustLinkResultDto +import com.runicgateway.app.data.api.dto.RustPlayerGrantDto +import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto +import com.runicgateway.app.data.api.dto.RustReachDto +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.fake.FakePlayerRustApi +import com.runicgateway.app.data.repository.PlayerRustRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.httpError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** + * The player's own Rust account (M15). + * + * The properties worth a test are the ones a screenshot cannot show: that the + * four refusals stay four different pieces of advice, that a failure on one half + * of the screen leaves the other half standing, and that a link re-reads what the + * site holds — because linking an account is the one action here that changes + * what reaches a game without changing anything the site authored. + */ +class RustAccountViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val api = FakePlayerRustApi() + private val repository = PlayerRustRepository(api) + + private fun viewModel() = RustAccountViewModel(repository) + + private fun linked(vararg ids: String) = + RustLinkListDto(ids.map { RustLinkDto(steamId = it, name = "Wanderer") }) + + @Test + fun `it reads both halves on open`() { + api.links = linked("7656119") + api.permissions = RustPlayerPermissionsDto(accounts = 1) + + val state = viewModel().state.value + + assertEquals(1, api.linksCalls) + assertEquals(1, api.permissionsCalls) + assertTrue(state.links is UiState.Success) + assertTrue(state.held is UiState.Success) + } + + @Test + fun `entitlements still render when the account list fails, and the other way round`() { + api.linksError = httpError(500) + api.permissions = RustPlayerPermissionsDto( + accounts = 0, + grants = listOf(RustPlayerGrantDto(permission = "kits.vip")), + ) + + // Only the links read throws; the permission read answers. An entitlement exists before a Steam id does, so a + // failed account read must not take it off the screen. + val state = viewModel().state.value + + assertTrue(state.links is UiState.Error) + assertTrue(state.held is UiState.Success) + assertEquals(1, (state.held as UiState.Success).data.grants.size) + } + + @Test + fun `a refused code is four different pieces of advice, never one`() { + val cases = mapOf( + 400 to R.string.rust_link_bad_code, + 409 to R.string.rust_link_taken, + 429 to R.string.rust_link_capped, + // The code is STILL GOOD here. A player told to get a new one would + // go back to the same unreachable server for it. + 503 to R.string.rust_link_unreachable, + ) + + for ((status, expected) in cases) { + val vm = viewModel() + api.linkError = httpError(status) + + vm.link("K7M2PQ") + + assertEquals("status $status", expected, vm.state.value.feedback?.messageRes) + assertFalse(vm.state.value.feedback!!.ok) + } + } + + @Test + fun `a dead network is not a refused code`() { + val vm = viewModel() + api.linkError = IOException("down") + + vm.link("K7M2PQ") + + assertEquals(R.string.error_network, vm.state.value.feedback?.messageRes) + } + + @Test + fun `linking again with an account already held is a success, not an error`() { + val vm = viewModel() + api.linkResult = RustLinkResultDto(linked = true, already = true) + + vm.link("K7M2PQ") + + assertEquals(R.string.rust_link_already, vm.state.value.feedback?.messageRes) + assertTrue(vm.state.value.feedback!!.ok) + } + + @Test + fun `a successful link re-reads what the site holds, not only the accounts`() { + val vm = viewModel() + val linksBefore = api.linksCalls + val permissionsBefore = api.permissionsCalls + + vm.link("K7M2PQ") + + assertEquals(linksBefore + 1, api.linksCalls) + assertEquals( + "a link changes what REACHES a game; the live marks are the only thing here it invalidates", + permissionsBefore + 1, + api.permissionsCalls, + ) + } + + @Test + fun `the code is trimmed and sent as typed`() { + val vm = viewModel() + + vm.link(" k7m2pq ") + + assertEquals("k7m2pq", api.lastCode) + } + + @Test + fun `a blank code asks nothing at all`() { + val vm = viewModel() + + vm.link(" ") + + assertEquals(null, api.lastCode) + } + + @Test + fun `releasing a link names that account and re-reads both halves`() { + val vm = viewModel() + val linksBefore = api.linksCalls + val permissionsBefore = api.permissionsCalls + + vm.unlink("7656119") + + assertEquals("7656119", api.lastUnlinked) + assertEquals(linksBefore + 1, api.linksCalls) + assertEquals(permissionsBefore + 1, api.permissionsCalls) + assertEquals(null, vm.state.value.unlinking) + } + + @Test + fun `a failed release says so and leaves the row alone`() { + val vm = viewModel() + api.unlinkError = httpError(404) + + vm.unlink("7656119") + + assertEquals(R.string.rust_unlink_error, vm.state.value.feedback?.messageRes) + assertEquals(null, vm.state.value.unlinking) + } + + @Test + fun `the servers an entitlement reaches arrive resolved, marks and all`() { + // The app does no scope arithmetic: `*` never reaches a screen. What + // arrives is a list of servers already marked, and this asserts the app + // keeps it that way rather than deriving anything of its own. + api.permissions = RustPlayerPermissionsDto( + accounts = 1, + grants = listOf( + RustPlayerGrantDto( + permission = "kits.vip", + scope = "*", + reach = listOf( + RustReachDto(id = "main", name = "Main", live = true), + RustReachDto(id = "creative", name = "Creative", live = false), + ), + ), + ), + ) + + val held = (viewModel().state.value.held as UiState.Success).data + + assertNotNull(held.grants.first().reach.first { it.id == "main" }) + assertTrue(held.grants.first().reach.first { it.id == "main" }.live) + assertFalse(held.grants.first().reach.first { it.id == "creative" }.live) + } + + @Test + fun `coins are a third read, and a link re-reads them`() { + api.economics = RustEconomicsDto( + servers = listOf( + RustEconomicsServerDto( + serverId = "main", + server = "Main", + economics = true, + accounts = listOf(RustBalanceDto(steamId = "7656119", balance = 1500.0, source = "live")), + ), + ), + ) + + val vm = viewModel() + val coins = vm.state.value.coins + assertTrue(coins is UiState.Success) + assertEquals(1500.0, (coins as UiState.Success).data.single().accounts.single().balance) + + val before = api.economicsCalls + vm.link("K7M2PQ") + assertEquals("a new link is a new account to read", before + 1, api.economicsCalls) + } + + @Test + fun `a backpack is read once per press, and a 409 is its own answer`() { + api.servers = RustServerListDto(listOf(RustServerDto(id = "main", name = "Main"))) + api.backpacks = mapOf( + "main|7656119" to RustBackpackDto(serverId = "main", steamId = "7656119", exists = true, capacity = 12, pageSize = 48), + ) + + val vm = viewModel() + assertEquals("nothing is read until it is opened", 0, api.backpackReads.size) + assertEquals("main", (vm.state.value.backpackServers as UiState.Success).data.single().id) + + vm.openBackpack("main", "7656119") + assertEquals(listOf("main|7656119"), api.backpackReads) + assertEquals(12, ((vm.state.value.backpack as UiState.Success).data).capacity) + assertEquals(RustAccountViewModel.BackpackKey("main", "7656119"), vm.state.value.backpackOpen) + + api.backpackError = httpError(409) + vm.openBackpack("main", "7656119") + assertEquals("pressing again reads again", 2, api.backpackReads.size) + assertEquals(409, (vm.state.value.backpack as UiState.Error).httpStatus) + + vm.closeBackpack() + assertEquals(null, vm.state.value.backpackOpen) + assertEquals(null, vm.state.value.backpack) + } + + @Test + fun `an older module's missing route leaves the rest of the screen alone`() { + api.economicsError = httpError(404) + api.permissions = RustPlayerPermissionsDto(accounts = 1) + + val state = viewModel().state.value + + assertTrue(state.held is UiState.Success) + assertEquals(404, (state.coins as UiState.Error).httpStatus) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustBadgeViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustBadgeViewModelTest.kt new file mode 100644 index 0000000..96067aa --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustBadgeViewModelTest.kt @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import com.runicgateway.app.data.api.fake.FakeRustApi +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.util.MainDispatcherRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** The drawer row's live count — the phone's answer to D15 (M14). */ +class RustBadgeViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val api = FakeRustApi() + private fun viewModel() = RustBadgeViewModel(RustRepository(api)) + + @Test + fun `it sums the people on every server`() { + api.servers = RustServerListDto( + listOf( + RustServerDto(id = "a", online = true, players = 12), + RustServerDto(id = "b", online = true, players = 30), + ), + ) + + val vm = viewModel() + vm.refresh(installed = true) + + assertEquals(42, vm.online.value) + } + + @Test + fun `a site without the module is never asked`() { + // The gate is the caller's — the drawer already knows, from the capability + // answer. A site running a different game makes no request at all. + val vm = viewModel() + vm.refresh(installed = false) + + assertEquals(0, api.serversCalls) + assertEquals(0, vm.online.value) + } + + @Test + fun `an unreachable server contributes nothing on its own`() { + // A stale or offline row already answers `online: false` with `players: 0` + // server-side, so there is no second staleness rule to keep in step here. + api.servers = RustServerListDto( + listOf( + RustServerDto(id = "a", online = true, players = 5), + RustServerDto(id = "b", online = false, players = 0, stale = true), + ), + ) + + val vm = viewModel() + vm.refresh(installed = true) + + assertEquals(5, vm.online.value) + } + + @Test + fun `a failed read keeps the last number rather than dropping to zero`() { + // A moment with no connectivity is not everybody logging off. + api.servers = RustServerListDto(listOf(RustServerDto(id = "a", online = true, players = 7))) + val vm = viewModel() + vm.refresh(installed = true) + + api.error = IOException("offline") + vm.refresh(installed = true) + + assertEquals(7, vm.online.value) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt new file mode 100644 index 0000000..bec8367 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt @@ -0,0 +1,227 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustBackpackDto +import com.runicgateway.app.data.api.dto.RustBackpackStackDto +import com.runicgateway.app.data.api.dto.RustEconomicsDto +import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustLeaderboardDto +import com.runicgateway.app.data.api.dto.RustServerListDto +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Decoding what the module actually answers with (M14). + * + * The JSON here is copied from `module-rust`'s own shape functions rather than + * invented, because the only thing worth testing about a DTO is whether it agrees + * with the other end. + */ +class RustDtoTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `an unreachable server decodes with everything it last said`() { + // The phase criterion in one object: `online: false`, `stale: true`, and + // every descriptive field still populated. This is what makes a page that + // renders while the game is off possible at all. + val list = json.decodeFromString( + """ + {"servers":[{ + "id":"main","name":"Main","online":false,"players":0,"maxPlayers":100, + "hostname":"Main | Vanilla","level":"Procedural Map","worldSize":4000, + "seed":1934567,"wipeId":"w-2026-09-04","wipedAt":"2026-09-04T18:00:00.000Z", + "lastSeenAt":"2026-09-14T10:12:00.000Z","updatedAt":"2026-09-16T13:59:30.000Z", + "stale":true + }]} + """.trimIndent(), + ) + + val server = list.servers.single() + assertFalse(server.online) + assertTrue(server.stale) + assertEquals("Procedural Map", server.level) + assertEquals(4000, server.worldSize) + // The two timestamps are two facts and both survive the wire. + assertEquals("2026-09-14T10:12:00.000Z", server.lastSeenAt) + assertEquals("2026-09-16T13:59:30.000Z", server.updatedAt) + } + + @Test + fun `a server that has never connected decodes with nulls, not zeroes`() { + val list = json.decodeFromString( + """{"servers":[{"id":"new","name":"New","online":false,"players":0,"maxPlayers":0, + "hostname":null,"level":null,"worldSize":null,"seed":null,"wipeId":null, + "wipedAt":null,"lastSeenAt":null,"updatedAt":null,"stale":true}]}""", + ) + + val server = list.servers.single() + assertNull(server.level) + assertNull(server.worldSize) + assertNull(server.lastSeenAt) + } + + @Test + fun `an unknown field does not break decoding`() { + // Protocol 2 is not the last one. A later plugin adds fields to a frame's + // envelope and an older app must keep reading the rest. + val list = json.decodeFromString( + """{"servers":[{"id":"main","name":"Main","somethingNew":{"a":1}}],"alsoNew":7}""", + ) + + assertEquals("main", list.servers.single().id) + } + + @Test + fun `a frame keeps whatever the plugin wrote`() { + val event = json.decodeFromString( + """{"id":9,"kind":"player.death","t":1789574400000,"wipeId":"w1","steamId":"765", + "frame":{"name":"Bob","attackerType":"player","attackerName":"Alice","distance":42.4}}""", + ) + + assertEquals("player.death", event.kind) + assertEquals(1789574400000L, event.t) + assertEquals("Alice", event.str("attackerName")) + assertEquals(42.4, event.num("distance")!!, 0.001) + assertNull("an absent field is null, not empty", event.str("weapon")) + assertFalse(event.flag("sleeping")) + } + + @Test + fun `a frame with no fields at all still decodes`() { + // `server.initialized` carries an envelope and nothing else, and the model + // answers an EMPTY frame for a stored row whose JSON will not parse — + // deliberately, so one bad row does not fail a whole page. + val event = json.decodeFromString( + """{"id":1,"kind":"server.initialized","t":1,"frame":{}}""", + ) + + assertNull(event.str("name")) + assertEquals(FeedTone.SERVER, describe(event).tone) + } + + // ── The next wipe (module-rust phase 16, M18) ────────────────────────── + + @Test + fun `the next wipe decodes, and is null against a module that does not send it`() { + val list = json.decodeFromString( + """{"servers":[ + {"id":"a","name":"A","nextWipe":{"at":"2026-10-01T18:00:00.000Z","source":"forced"}}, + {"id":"b","name":"B","nextWipe":null}, + {"id":"c","name":"C"} + ]}""", + ) + val (a, b, c) = list.servers + assertEquals("2026-10-01T18:00:00.000Z", a.nextWipe?.at) + assertEquals("forced", a.nextWipe?.source) + // No schedule, and a module older than phase 16: the same answer, nothing drawn. + assertNull(b.nextWipe) + assertNull(c.nextWipe) + } + + @Test + fun `a leaderboard row carries its chat titles, and an older module's carries none`() { + // M19. The shape is module-rust's `listLeaderboard`: `titles` on every + // row, empty for a player with none; absent altogether from a module + // older than phase 17, which must decode to empty rather than fail. + val board = json.decodeFromString( + """ + {"leaderboard":[ + {"steamId":"1","name":"Brannock","kills":12,"deaths":3,"npcKills":0,"structures":0,"playtimeSec":3600, + "titles":[{"text":"Top Killer","color":"#ff8800"},{"text":"Regular","color":"#00aa55"}]}, + {"steamId":"2","name":"Wren","kills":1,"deaths":0,"npcKills":0,"structures":0,"playtimeSec":60,"titles":[]} + ]} + """.trimIndent(), + ) + assertEquals(listOf("Top Killer", "Regular"), board.leaderboard[0].titles.map { it.text }) + assertEquals("#ff8800", board.leaderboard[0].titles[0].color) + assertTrue(board.leaderboard[1].titles.isEmpty()) + + val older = json.decodeFromString( + """{"leaderboard":[{"steamId":"1","name":"Brannock","kills":12}]}""", + ) + assertTrue(older.leaderboard.single().titles.isEmpty()) + } + + @Test + fun `coin balances decode, a server without Economics included`() { + // The shape of module-rust's economics.model balancesOf(). + val coins = json.decodeFromString( + """ + {"servers":[ + {"serverId":"main","server":"Main","economics":true,"startingBalance":1000,"startingKnown":false,"live":false, + "accounts":[{"steamId":"76561198000000001","name":"Wanderer","balance":1500.5,"source":"last-known"}, + {"steamId":"76561198000000002","name":null,"balance":null,"source":null}]}, + {"serverId":"pvp","server":"PvP","economics":false,"reason":"Economics is not installed on this server","accounts":[]} + ]} + """.trimIndent(), + ) + val main = coins.servers.first() + assertTrue(main.economics) + assertFalse(main.startingKnown) + assertEquals(1500.5, main.accounts.first().balance!!, 0.0) + assertEquals("last-known", main.accounts.first().source) + assertNull(main.accounts[1].balance) + assertFalse(coins.servers[1].economics) + assertEquals("Economics is not installed on this server", coins.servers[1].reason) + } + + @Test + fun `a backpack decodes, nested contents and a never-had-one included`() { + // The shape of module-rust's backpacks.model read(). + val bp = json.decodeFromString( + """ + {"serverId":"main","server":"Main","steamId":"76561198000000001","exists":true,"capacity":12,"pageSize":48, + "items":[{"slot":2,"shortname":"rifle.ak","display":"Assault Rifle","amount":1, + "contents":[{"slot":0,"shortname":"weapon.mod.lasersight","display":"Weapon Lasersight","amount":1}]}, + {"slot":3,"shortname":"hoodie","display":"Hoodie","amount":1,"skin":"10129"}, + {"slot":5,"shortname":"blueprintbase","display":"Assault Rifle","blueprint":"rifle.ak","amount":1}, + {"slot":14,"shortname":null,"itemId":999,"amount":1,"overflow":true}]} + """.trimIndent(), + ) + assertTrue(bp.exists) + assertEquals(12, bp.capacity) + assertEquals("Weapon Lasersight", bp.items[0].contents.single().display) + assertEquals("10129", bp.items[1].skin) + assertEquals("rifle.ak", bp.items[2].blueprint) + assertNull(bp.items[3].shortname) + assertTrue(bp.items[3].overflow) + + val never = json.decodeFromString("""{"serverId":"main","server":"Main","steamId":"1","exists":false}""") + assertFalse(never.exists) + assertTrue(never.items.isEmpty()) + } + + @Test + fun `a backpack's slots run across pages, overflow drawn after the capacity`() { + assertTrue(backpackPages(null).isEmpty()) + assertTrue(backpackPages(RustBackpackDto(exists = false)).isEmpty()) + + val twoPages = backpackPages( + RustBackpackDto(exists = true, capacity = 96, pageSize = 48, items = listOf(RustBackpackStackDto(slot = 50, shortname = "note"))), + ) + assertEquals(2, twoPages.size) + assertEquals(48, twoPages[1].cells.size) + assertEquals("note", twoPages[1].cells[2].stack?.shortname) + + // A size revoked at the end of a run: capacity 6, notes still on what was page two. + val shrunk = backpackPages( + RustBackpackDto(exists = true, capacity = 6, pageSize = 48, items = listOf(48, 49).map { RustBackpackStackDto(slot = it, overflow = true) }), + ) + assertEquals(2, shrunk.size) + assertFalse(shrunk[0].cells[5].overflow) + assertTrue(shrunk[0].cells[6].overflow) + assertEquals(2, shrunk[1].cells.size) + + assertEquals("Assault Rifle blueprint", stackLabel(RustBackpackStackDto(display = "Assault Rifle", blueprint = "rifle.ak"))) + assertEquals("wood", stackLabel(RustBackpackStackDto(shortname = "wood"))) + assertEquals("item 999", stackLabel(RustBackpackStackDto(itemId = 999))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt new file mode 100644 index 0000000..be172c8 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt @@ -0,0 +1,169 @@ +/* + * 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 kill prints the weapon name the module sends`() { + // Module-Rust#60: the module reads the game's own name off its table and + // sends it beside the prefab, which stays the counting key. + val line = describe( + row( + "player.death", + """{"name":"Bob","attackerType":"player","attackerName":"Alice", + "weapon":"ak47u.entity","weaponName":"Assault Rifle"}""", + ), + ) + + assertEquals("with Assault Rifle", line.detail) + } + + @Test + fun `an older module's kill drops the prefab ending rather than printing it`() { + // A module from before #60 sends no `weaponName`. The feed once said + // "with ak47u entity"; the ending is dropped the way the website does it. + val line = describe( + row( + "player.death", + """{"name":"Bob","attackerType":"player","attackerName":"Alice","weapon":"ak47u.entity"}""", + ), + ) + + assertEquals("with ak47u", line.detail) + } + + @Test + fun `a fall is a death by nobody, not a kill`() { + // The failure this distinction exists to avoid: `HitInfo` is legitimately + // null when the world kills somebody, so an ABSENT attacker type is the + // environment case rather than a missing field. Reporting it as a kill by + // nobody is the bug. + val line = describe(row("player.death", """{"name":"Bob"}""")) + + assertEquals(FeedTone.DEATH, line.tone) + assertEquals("Bob", line.actor) + assertEquals("died", line.verb) + assertNull(line.subject) + } + + @Test + fun `an NPC kill reads the prefab as words`() { + val line = describe( + row("player.death", """{"name":"Bob","attackerType":"npc","attackerName":"patrolhelicopter"}"""), + ) + + assertEquals("patrolhelicopter", line.actor) + assertEquals("Bob", line.subject) + } + + @Test + fun `a suicide names one person once`() { + val line = describe(row("player.death", """{"name":"Bob","attackerType":"self"}""")) + + assertEquals("Bob", line.actor) + assertNull(line.subject) + } + + @Test + fun `chat puts the colon in the join, never in the message`() { + val line = describe(row("player.chat", """{"name":"Bob","message":"see you in september"}""")) + + assertEquals(": ", line.join) + assertEquals("see you in september", line.verb) + } + + @Test + fun `a disconnect with no session reports only the reason`() { + // The plugin OMITS `sessionSec` for a player who was already connected when + // it loaded, so an absent value means "unknown" and must not become "after + // 0s" — which is what a DTO default of zero would produce if it were read + // without this guard. + val line = describe(row("player.disconnected", """{"name":"Bob","reason":"Disconnected"}""")) + + assertEquals("Disconnected", line.detail) + } + + @Test + fun `a JSON null field does not become the word null`() { + // A primitive's `content` is literally "null" for a JSON null, and the + // plugin writes explicit nulls — so a naive read puts the four letters + // into a killfeed line. + val line = describe(row("player.disconnected", """{"name":"Bob","reason":null,"sessionSec":null}""")) + + assertEquals("", line.detail) + } + + @Test + fun `an unknown kind renders as itself rather than vanishing`() { + // A later protocol adds kinds and an operator's module may be older than + // their game host. The server's allowlist has already decided the row may + // be seen; dropping it here would be the screen quietly saying less than + // the truth. + val line = describe(row("player.teleported", """{"name":"Bob"}""")) + + assertEquals(FeedTone.OTHER, line.tone) + assertEquals("player.teleported", line.verb) + assertEquals("Bob", line.actor) + } + + @Test + fun `the tally kind is not in the feed's own list`() { + // It is an aggregate the plugin flushes every sixty seconds per active + // player, so a feed carrying it would be mostly wood counts. It is the + // leaderboard's input. + assertTrue("player.tally" !in FEED_KINDS) + } + + @Test + fun `every filter asks for kinds the feed knows`() { + for (filter in FEED_FILTERS) { + assertTrue( + "${filter.id} asks for a kind the feed does not list", + FEED_KINDS.containsAll(filter.kinds), + ) + } + } + + @Test + fun `an unknown filter id falls back to everything`() { + assertEquals(FEED_KINDS, kindsFor("nonsense")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt new file mode 100644 index 0000000..0f2dc9e --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 weapon prints the module's name, or the prefab without its ending`() { + assertEquals("Assault Rifle", weaponName("ak47u.entity", "Assault Rifle")) + // An older module sends no name; neither does one that sends a blank. + assertEquals("ak47u", weaponName("ak47u.entity", null)) + assertEquals("ak47u", weaponName("ak47u.entity", "")) + assertEquals("salvaged sword", weaponName("salvaged_sword.weapon", null)) + assertEquals("autoturret", weaponName("autoturret.deployed", null)) + assertEquals("rifle ak", weaponName("rifle.ak", null)) + // The ending is dropped only at the end. + assertEquals("entity thing", weaponName("entity.thing", null)) + // No weapon, no name, even when a name came along. + assertEquals("", weaponName(null, "Assault Rifle")) + } + + @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)) + } + + // ── The next wipe (module-rust phase 16, M18) ────────────────────────── + + @Test + fun `the next wipe is said in the phone's zone with how far away it is`() { + val sept25 = Instant.parse("2026-09-25T12:00:00Z") + // The forced wipe: 18:00 UTC is 19:00 in London in summer, 13:00 in Chicago. + assertEquals( + "Thu 1 Oct, 19:00 · in 6 days", + nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("Europe/London"), uk), + ) + assertTrue( + nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("America/Chicago"), Locale.US)!! + .startsWith("Thu 1 Oct, 1:00"), + ) + } + + @Test + fun `no instant is no line`() { + assertNull(nextWipeWhen(null, now, utc, uk)) + assertNull(nextWipeWhen("soon", now, utc, uk)) + } + + @Test + fun `a title colour is read only as #rrggbb, and its ink is whichever reads`() { + assertEquals(0xFFFFAA55L, titleArgb("#ffaa55")) + assertEquals(0xFFFFAA55L, titleArgb("#FFAA55")) + assertNull(titleArgb("red")) + assertNull(titleArgb(null)) + + // The same four the website's contrastInk test asserts, so the two + // clients draw a title the same way. + assertTrue(titleInkIsDark(titleArgb("#ffff00")!!)) + assertTrue(titleInkIsDark(titleArgb("#ffaa55")!!)) + assertTrue(titleInkIsDark(titleArgb("#ff0000")!!)) + assertFalse(titleInkIsDark(titleArgb("#1a1a8c")!!)) + } + + @Test + fun `a balance never reported is a dash, not zero coins`() { + assertEquals("—", coinAmount(null, Locale.UK)) + assertEquals("—", coinAmount(Double.NaN, Locale.UK)) + assertEquals("0", coinAmount(0.0, Locale.UK)) + assertEquals("1,500.46", coinAmount(1500.456, Locale.UK)) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt new file mode 100644 index 0000000..7287c63 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt @@ -0,0 +1,136 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventOccurrenceDto +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The map's wire, decoded the way the app decodes it (`NetworkModule`). + * + * **Absent is not empty on this wire.** The module removes a layer the viewer may + * not see, so a missing key means *not yours* and an empty list means *yours, and + * nothing is there*. A default of `emptyList()` would blur the two, which is why + * every layer is nullable. + */ +class RustMapDtoTest { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + @Test + fun `a live answer with every layer absent decodes, and every layer is null`() { + val live = json.decodeFromString("""{"live":true,"mapKey":"3000.1234.1"}""") + assertTrue(live.live) + assertNull(live.world) + assertNull(live.events) + assertNull(live.players) + assertNull(live.bases) + assertNull(live.mates) + assertEquals(emptyList(), mapMarkers(RustMapDto(), live, MapLayer.entries.toSet())) + } + + @Test + fun `an empty layer is present, and different from an absent one`() { + val live = json.decodeFromString("""{"live":true,"world":[],"players":null}""") + assertNotNull(live.world) + assertTrue(live.world!!.isEmpty()) + assertNull(live.players) + } + + @Test + fun `a game that did not answer keeps its reason`() { + val live = json.decodeFromString("""{"live":false,"reason":"timeout"}""") + assertFalse(live.live) + assertEquals("timeout", live.reason) + } + + @Test + fun `the map as the rig answers it`() { + val map = json.decodeFromString( + """ + {"serverId":"main","mapKey":"3000.1234.1", + "picture":{"path":"/public/rust/servers/main/map/image?v=28da6e8a","source":"companion","fetchedAt":null}, + "geometry":{"worldSize":3000,"oceanMargin":500,"width":2500,"height":2500,"gridCells":20,"gridCellSize":150,"background":"#0B3B4A"}, + "monuments":[{"value":"harbor_1#2","kind":"harbor_1","label":"Harbor","grid":"O3","x":678.1,"z":1005.7}], + "layers":{"world":{"visible":true,"audience":"public"},"events":{"visible":true,"audience":"public"}, + "players":{"visible":false,"audience":"staff","cappedByPresence":true},"bases":{"visible":false,"audience":"staff"}}, + "mates":{"visible":false,"on":true,"linked":false,"signedIn":true}, + "pollMs":10000} + """.trimIndent(), + ) + assertEquals(2500.0, map.geometry!!.width, 0.0) + assertEquals("O3", map.monuments!!.single().grid) + assertTrue(map.layers.players.cappedByPresence) + assertFalse(map.layers.players.visible) + assertTrue(map.anyLive) + assertEquals(10_000L, map.pollMs) + } + + @Test + fun `a server that never described its map has no geometry and no picture`() { + val map = json.decodeFromString("""{"serverId":"main","mapKey":null,"picture":null,"geometry":null}""") + assertNull(map.geometry) + assertNull(map.picture) + assertFalse(map.anyLive) + } + + @Test + fun `a site event's run id arrives as the plugin's string`() { + val live = json.decodeFromString( + """{"live":true,"events":[{"kind":"zone","runId":"41","x":1,"z":2,"radius":60,"name":"Harbor brawl"}]}""", + ) + assertEquals("41", live.events!!.single().runId) + } + + @Test + fun `a calendar run carries core's run id as a number, and a projection none`() { + // D125. A core older than it omits the field, which decodes to null. + val calendar = json.decodeFromString( + """{"entries":[{"kind":"run","runId":41,"slug":"a"},{"kind":"projected","slug":"b"},{"kind":"run","slug":"c"}]}""", + ) + assertEquals(listOf(41L, null, null), calendar.entries.map { it.runId }) + } + + @Test + fun `runicnpc stage 8 - NPC rows and a world boss decode, and an older row without them still does`() { + val live = json.decodeFromString( + """{"live":true, + "world":[{"kind":"boss","x":1,"z":2,"name":"The Warden"},{"kind":"cargo","x":3,"z":4}], + "events":[{"kind":"npc","runId":"41","x":0,"z":0,"prefab":"profile:jugg","key":"k1","name":"The Juggernaut","boss":true}, + {"kind":"npc","runId":"41","x":0,"z":0,"name":"Bandit","add":true}, + {"kind":"zone","runId":"41","x":0,"z":0,"radius":50}]}""", + ) + assertEquals("The Warden", live.world!![0].name) + assertNull(live.world!![1].name) + val events = live.events!! + assertTrue(events[0].boss) + assertFalse(events[0].add) + assertTrue(events[1].add) + assertFalse(events[2].boss) + } + + @Test + fun `an occurrence's progress decodes, and an older answer without it is an empty list`() { + val withLines = json.decodeFromString( + """{"runId":41,"live":true,"progress":[{"label":"Bandits","left":3,"of":8},{"label":"The Juggernaut","percent":62}]}""", + ) + assertEquals(listOf("Bandits", "The Juggernaut"), withLines.progress.map { it.label }) + assertEquals(3, withLines.progress[0].left) + assertEquals(62, withLines.progress[1].percent) + assertEquals(emptyList(), json.decodeFromString("""{"runId":41}""").progress) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt new file mode 100644 index 0000000..b1c7781 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt @@ -0,0 +1,258 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.SavedStateHandle +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.auth.StoredSession +import com.runicgateway.app.core.auth.TokenStore +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventCalendarEntryDto +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapEventDto +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import com.runicgateway.app.data.api.dto.RustMapLayerDto +import com.runicgateway.app.data.api.dto.RustMapLayersDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.data.api.dto.RustMapMatesDto +import com.runicgateway.app.data.api.dto.RustMapPlayerDto +import com.runicgateway.app.data.api.dto.RustMapWorldDto +import com.runicgateway.app.data.api.dto.SafeUserDto +import com.runicgateway.app.data.api.fake.FakeEventsApi +import com.runicgateway.app.data.api.fake.FakeRustApi +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** One server's map: when it asks, and whose answer it draws (§31.2). */ +class RustMapViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val api = FakeRustApi() + private val eventsApi = FakeEventsApi() + + private class FakeTokenStore(private var stored: StoredSession?) : TokenStore { + override fun load(): StoredSession? = stored + override fun save(session: StoredSession) { stored = session } + override fun clear() { stored = null } + } + + private fun sessionFor(userId: Long?) = SessionManager( + FakeTokenStore(userId?.let { StoredSession("a", "r", it, "u$it", "player") }), + ) + + private fun user(id: Long) = SafeUserDto(id = id, username = "u$id", role = "player") + + private fun viewModel(sessions: SessionManager = sessionFor(null)) = RustMapViewModel( + RustRepository(api), + EventsRepository(eventsApi), + sessions, + SavedStateHandle(mapOf("serverId" to "main")), + ) + + private val geometry = RustMapGeometryDto(worldSize = 3000.0, oceanMargin = 500.0, width = 2500.0, height = 2500.0) + private val public = RustMapLayerDto(visible = true, audience = "public") + private val staffOnly = RustMapLayerDto(visible = false, audience = "staff") + + private fun map(key: String = "3000.1234.1", mates: Boolean = false) = RustMapDto( + serverId = "main", + mapKey = key, + geometry = geometry, + layers = RustMapLayersDto(world = public, events = public, players = staffOnly, bases = staffOnly), + mates = RustMapMatesDto(visible = mates, on = true, linked = mates, signedIn = mates), + pollMs = 10_000, + ) + + private fun mine(steamId: String) = RustMapLiveDto( + live = true, + mapKey = "3000.1234.1", + mates = listOf(RustMapPlayerDto(steamId = steamId, x = 1.0, z = 1.0, online = true, self = true)), + ) + + @Test + fun `it reads the map once and asks nothing that moves until the poll`() { + api.map = map() + val vm = viewModel() + assertTrue(vm.state.value.map is UiState.Success) + assertEquals(1, api.mapCalls) + assertEquals(0, api.mapLiveCalls) + + vm.poll() + assertEquals(1, api.mapLiveCalls) + } + + @Test + fun `a viewer sent nothing that moves is never polled`() { + api.map = map().copy(layers = RustMapLayersDto(world = staffOnly, events = staffOnly, players = staffOnly, bases = staffOnly)) + val vm = viewModel() + vm.poll() + assertEquals(0, api.mapLiveCalls) + } + + @Test + fun `a server that never described its map is not polled`() { + api.map = map().copy(geometry = null) + val vm = viewModel() + vm.poll() + assertEquals(0, api.mapLiveCalls) + } + + @Test + fun `a module older than phase 14 is an error with a retry, not a crash`() { + api.error = httpError(404) + val vm = viewModel() + assertTrue(vm.state.value.map is UiState.Error) + } + + @Test + fun `a failed poll keeps the last positions drawn`() { + api.map = map() + api.mapLive = RustMapLiveDto(live = true, world = listOf(RustMapWorldDto("cargo", 1.0, 1.0))) + val vm = viewModel() + vm.poll() + + api.mapLiveQueue.add { throw IOException("offline") } + vm.poll() + + assertEquals("cargo", vm.state.value.live?.world?.single()?.kind) + assertTrue(vm.state.value.liveFailed) + } + + @Test + fun `a new map key re-reads the map without leaving the tab`() { + api.map = map(key = "3000.1234.1") + val vm = viewModel() + + api.map = map(key = "3000.5678.2") + api.mapLive = RustMapLiveDto(live = true, mapKey = "3000.5678.2") + vm.poll() + + assertEquals(2, api.mapCalls) + assertEquals("3000.5678.2", (vm.state.value.map as UiState.Success).data.mapKey) + } + + @Test + fun `the same map key does not re-read the map`() { + api.map = map() + api.mapLive = RustMapLiveDto(live = true, mapKey = "3000.1234.1") + val vm = viewModel() + repeat(3) { vm.poll() } + assertEquals(1, api.mapCalls) + } + + // ── Per account ──────────────────────────────────────────────────────── + + @Test + fun `another account sees none of the first account's dots before its own answer`() { + // Walk step 3, as a test. The server screen's back-stack entry outlives a + // sign-out, and events phase 14b found a view model that loads once shows + // the next account the previous one's answer without asking. + val sessions = sessionFor(33) + api.map = map(mates = true) + api.mapLive = mine("first") + val vm = viewModel(sessions) + vm.poll() + assertEquals("first", vm.state.value.live?.mates?.single()?.steamId) + + sessions.onSignedIn("a2", "r2", user(34)) + + // Nothing of the first account's is left drawn, and the map was asked again. + assertNull(vm.state.value.live) + assertNull(vm.state.value.selected) + assertEquals(2, api.mapCalls) + + api.mapLive = mine("second") + vm.poll() + assertEquals("second", vm.state.value.live?.mates?.single()?.steamId) + } + + @Test + fun `an answer asked for the previous account is thrown away when it lands`() { + val sessions = sessionFor(33) + api.map = map(mates = true) + val vm = viewModel(sessions) + + val gate = CompletableDeferred() + api.mapLiveQueue.add { + gate.await() + mine("first") + } + vm.poll() + + sessions.onSignedIn("a2", "r2", user(34)) + gate.complete(Unit) + + assertNull(vm.state.value.live) + } + + @Test + fun `signing out still shows the public map, not an empty one`() { + val sessions = sessionFor(33) + api.map = map(mates = true) + api.mapLive = mine("first") + val vm = viewModel(sessions) + vm.poll() + + api.map = map(mates = false) + sessions.onSignedOut() + + assertNull(vm.state.value.live) + assertTrue(vm.state.value.map is UiState.Success) + assertEquals(false, (vm.state.value.map as UiState.Success).data.mates.visible) + } + + @Test + fun `the reader's switches survive a change of account`() { + val sessions = sessionFor(33) + api.map = map() + val vm = viewModel(sessions) + vm.toggle(MapLayer.GRID) + + sessions.onSignedIn("a2", "r2", user(34)) + + assertTrue(MapLayer.GRID !in vm.state.value.shown) + } + + // ── The event link ───────────────────────────────────────────────────── + + @Test + fun `a listed run's marker resolves to its event, and a rehearsal's does not`() { + api.map = map() + api.mapLive = RustMapLiveDto( + live = true, + events = listOf( + RustMapEventDto(kind = "zone", runId = "41", radius = 60.0, name = "Harbor brawl"), + // A rehearsal: absent from the public calendar. + RustMapEventDto(kind = "zone", runId = "77", radius = 60.0), + ), + ) + eventsApi.calendar = EventCalendarDto(listOf(EventCalendarEntryDto(kind = "run", runId = 41, slug = "harbor-brawl"))) + val vm = viewModel() + vm.poll() + + assertEquals(mapOf("41" to "harbor-brawl"), vm.state.value.eventSlugs) + } + + @Test + fun `hiding a layer closes the card for a marker on it`() { + api.map = map() + val vm = viewModel() + vm.select(MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41")) + + vm.toggle(MapLayer.EVENTS) + + assertNull(vm.state.value.selected) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt new file mode 100644 index 0000000..ce3ee6b --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt @@ -0,0 +1,307 @@ +/* + * 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.InstalledModuleDto +import com.runicgateway.app.data.api.dto.ModulesDto +import com.runicgateway.app.data.api.dto.RustEventListDto +import com.runicgateway.app.data.api.dto.RustLeaderboardDto +import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustOnlineDto +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.dto.StatusDto +import com.runicgateway.app.data.api.dto.VersionDto +import com.runicgateway.app.data.api.fake.FakePublicApi +import com.runicgateway.app.data.api.fake.FakeRustApi +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.data.repository.SiteCapabilitiesRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import kotlinx.coroutines.test.runTest +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 val publicApi = FakePublicApi() + private val capabilities = SiteCapabilitiesRepository(publicApi) + + private fun viewModel(id: String = "main", tab: String? = null) = RustServerViewModel( + repository, + capabilities, + SavedStateHandle(mapOf("serverId" to id, "tab" to tab)), + ) + + @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 `withheld names arrive as withheld, with the count, never as an empty list`() { + // Nothing names who is online by default (org lead, 2026-09-22). The + // screen's whole job with this answer is to say "12 online" rather than + // "nobody is on", so the state must carry `hidden` and `count` intact. + api.online = RustOnlineDto(players = emptyList(), hidden = true, count = 12, audience = "staff") + val vm = viewModel() + + vm.selectTab(RustTab.ONLINE) + + val state = vm.state.value.online.state + assertTrue(state is UiState.Success) + val answer = (state as UiState.Success).data + assertTrue(answer.hidden) + assertEquals(12, answer.count) + assertEquals("staff", answer.audience) + } + + @Test + fun `a feed with its players withheld says so in the state`() { + api.events = RustEventListDto(events = emptyList(), presenceHidden = true, presenceAudience = "signed_in") + val vm = viewModel() + + val state = vm.state.value.feed.state + assertTrue(state is UiState.Success) + assertTrue((state as UiState.Success).data.presenceHidden) + assertEquals("signed_in", state.data.presenceAudience) + } + + @Test + fun `a poll on a still panel asks for nothing but the server line`() { + api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1"))) + val vm = viewModel() + + vm.selectTab(RustTab.WIPES) + val wipesBefore = api.wipeCalls + val feedBefore = api.eventCalls + vm.refresh() + + assertEquals(wipesBefore, api.wipeCalls) + assertEquals(feedBefore, api.eventCalls) + } + + @Test + fun `choosing a wipe re-asks the feed and, if it is loaded, the leaderboard`() { + api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1"))) + val vm = viewModel() + + vm.selectWipe("wipe-1") + assertEquals("wipe-1", api.lastFeedWipe) + // Nobody has opened the leaderboard, so nothing was fetched for it. + assertEquals(0, api.leaderboardCalls) + + vm.selectTab(RustTab.LEADERBOARD) + vm.selectWipe("wipe-2") + assertEquals("wipe-2", api.lastLeaderboardWipe) + assertEquals("wipe-2", api.lastFeedWipe) + } + + @Test + fun `picking the same wipe twice asks nothing`() { + val vm = viewModel() + vm.selectWipe("wipe-1") + val calls = api.eventCalls + + vm.selectWipe("wipe-1") + + assertEquals(calls, api.eventCalls) + } + + @Test + fun `opening a wipe from the wipes tab lands on the feed`() { + // Picking a wipe there is a navigation as much as a filter: the question is + // "what happened during that map", and the answer is the feed. + val vm = viewModel() + vm.selectTab(RustTab.WIPES) + + vm.openWipe("wipe-1") + + assertEquals(RustTab.FEED, vm.state.value.tab) + assertEquals("wipe-1", vm.state.value.selectedWipe) + } + + @Test + fun `a 404 on the server read is its own state, not a generic error`() { + // A mistyped address is not a fault. The screen reads NOT_FOUND and says + // "no such server" rather than dressing it as an outage. + api.error = HttpException(Response.error(404, "".toResponseBody(null))) + + val vm = viewModel("typo") + + val state = vm.state.value.server.state + assertTrue(state is UiState.Error) + assertEquals(404, (state as UiState.Error).httpStatus) + } + + @Test + fun `a failed poll keeps the server line that was there`() { + api.server = RustServerResponse(RustServerDto(id = "main", name = "Main")) + val vm = viewModel() + + api.error = IOException("offline") + vm.refresh() + + val state = vm.state.value.server.state + assertTrue(state is UiState.Success) + assertEquals("Main", (state as UiState.Success).data.name) + assertTrue(vm.state.value.server.refreshFailed) + } + + @Test + fun `sorting sends the API's own vocabulary`() { + val vm = viewModel() + vm.selectTab(RustTab.LEADERBOARD) + + vm.selectSort(RustSort.PLAYTIME) + + assertEquals("playtime", api.lastSort) + } + + // ── The Map tab (Rust phase 15, D122) ───────────────────────────────── + + private suspend fun serving(vararg moduleCaps: String) { + publicApi.status = StatusDto(version = VersionDto(capabilities = listOf("events"))) + publicApi.modules = ModulesDto(listOf(InstalledModuleDto(id = "rust", capabilities = moduleCaps.toList()))) + capabilities.refresh() + } + + @Test + fun `the map sits between Online and Wipes where the module declares it`() = runTest { + serving("rust", "map") + val vm = viewModel() + assertEquals( + listOf(RustTab.FEED, RustTab.LEADERBOARD, RustTab.ONLINE, RustTab.MAP, RustTab.WIPES), + vm.state.value.tabs, + ) + } + + @Test + fun `a site whose module does not declare map shows no Map tab`() = runTest { + // Walk step 7, as a test, since every running core here has the map. + serving("rust", "servers") + val vm = viewModel() + assertTrue(RustTab.MAP !in vm.state.value.tabs) + } + + @Test + fun `a tab=map link on a site without a map opens the feed`() = runTest { + serving("rust") + val vm = viewModel(tab = "map") + assertEquals(RustTab.FEED, vm.state.value.tab) + } + + @Test + fun `a tab=map link on a site with one opens the map and asks for no panel`() = runTest { + serving("rust", "map") + val vm = viewModel(tab = "map") + assertEquals(RustTab.MAP, vm.state.value.tab) + // The map's own view model does the asking, when the tab composes. + assertEquals(0, api.leaderboardCalls + api.onlineCalls + api.wipeCalls) + } + + @Test + fun `a host that has never answered leaves the Map tab open`() { + // The app's one capability rule (§31.4): absence of an answer is not an + // answer of absence. A module older than phase 14 then 404s in the tab. + val vm = viewModel(tab = "map") + assertTrue(RustTab.MAP in vm.state.value.tabs) + assertEquals(RustTab.MAP, vm.state.value.tab) + } + + @Test + fun `the Map tab going away under the reader lands them on the feed`() = runTest { + serving("rust", "map") + val vm = viewModel(tab = "map") + assertEquals(RustTab.MAP, vm.state.value.tab) + + serving("rust") + + assertEquals(RustTab.FEED, vm.state.value.tab) + assertTrue(RustTab.MAP !in vm.state.value.tabs) + } + + @Test + fun `the screen's poll leaves the map to its own cadence`() = runTest { + serving("rust", "map") + val vm = viewModel(tab = "map") + val feedCalls = api.eventCalls + vm.refresh() + assertEquals(feedCalls, api.eventCalls) + assertEquals(0, api.onlineCalls) + } +}