Compare commits
16 Commits
a6677d5bf9
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| 63bdf824be | |||
| 96aae7239b | |||
| 341c13a98c | |||
| 89c317f694 | |||
| edcbc0727b | |||
| 09eb101c52 | |||
| adf9547e01 | |||
| 640b423dfa | |||
| 8e3c62d966 | |||
| f695eb6c45 | |||
| 59d955a11d | |||
| a079bd481e | |||
| 4878b74e09 | |||
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 |
@@ -45,8 +45,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: ''
|
||||||
|
|
||||||
# Install exactly what the build targets so it never depends on AGP's
|
# Install exactly what the build targets so it never depends on AGP's
|
||||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||||
|
|||||||
@@ -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:<id>` 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>): 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
|
||||||
@@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.runicgateway.app.MainActivity
|
import com.runicgateway.app.MainActivity
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -20,10 +21,18 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the notification channels and posts a notification for a received tickle
|
* 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**
|
* (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity],
|
||||||
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
|
* which fetches fresh over the authenticated API.
|
||||||
* carries nothing to render, so nothing is fetched to display the notification; 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
|
@Singleton
|
||||||
class PushNotifier @Inject constructor(
|
class PushNotifier @Inject constructor(
|
||||||
@@ -65,17 +74,32 @@ class PushNotifier @Inject constructor(
|
|||||||
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
||||||
.build()
|
.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
|
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
||||||
val title = context.getString(PushStreams.titleRes(tickle.stream))
|
val generic = context.getString(PushStreams.titleRes(tickle.stream))
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
val content = notificationText(item, generic)
|
||||||
.setContentTitle(title)
|
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)
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(redacted)
|
||||||
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
||||||
.build()
|
content.body?.let { body ->
|
||||||
|
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
}
|
||||||
|
val notification = builder.build()
|
||||||
try {
|
try {
|
||||||
manager.notify(nextId.getAndIncrement(), notification)
|
manager.notify(nextId.getAndIncrement(), notification)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
@@ -108,3 +132,18 @@ class PushNotifier @Inject constructor(
|
|||||||
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
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() })
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -33,6 +32,7 @@ class PushService : Service() {
|
|||||||
|
|
||||||
@Inject lateinit var streamClient: NtfyStreamClient
|
@Inject lateinit var streamClient: NtfyStreamClient
|
||||||
@Inject lateinit var notifier: PushNotifier
|
@Inject lateinit var notifier: PushNotifier
|
||||||
|
@Inject lateinit var content: PushContentResolver
|
||||||
@Inject lateinit var prefs: PushPreferences
|
@Inject lateinit var prefs: PushPreferences
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -67,8 +67,15 @@ class PushService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
|
// Each tickle in its own job (D70). Titling one from its inbox row
|
||||||
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
|
// 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)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
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.RustUnlinkResultDto
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `/<module id>/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
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ package com.runicgateway.app.data.api
|
|||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
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.RustOnlineDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||||
@@ -91,4 +93,19 @@ interface RustApi {
|
|||||||
/** The presence board, which an unreachable server does not clear. */
|
/** The presence board, which an unreachable server does not clear. */
|
||||||
@GET("api/v1/public/rust/servers/{id}/online")
|
@GET("api/v1/public/rust/servers/{id}/online")
|
||||||
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable
|
|||||||
*
|
*
|
||||||
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||||
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
* 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
|
@Serializable
|
||||||
data class EventCalendarEntryDto(
|
data class EventCalendarEntryDto(
|
||||||
val kind: String = "run",
|
val kind: String = "run",
|
||||||
|
val runId: Long? = null,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val seriesName: String? = null,
|
val seriesName: String? = null,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* 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<RustLinkDto> = 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<RustPlayerGroupDto> = emptyList(),
|
||||||
|
val grants: List<RustPlayerGrantDto> = 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<String> = emptyList(),
|
||||||
|
val reach: List<RustReachDto> = 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<RustReachDto> = 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,
|
||||||
|
)
|
||||||
@@ -68,12 +68,40 @@ data class RustServerDto(
|
|||||||
/** When this module last wrote the row — a failed poll moves it too. */
|
/** When this module last wrote the row — a failed poll moves it too. */
|
||||||
val updatedAt: String? = null,
|
val updatedAt: String? = null,
|
||||||
val stale: Boolean = false,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */
|
/**
|
||||||
|
* 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
|
@Serializable
|
||||||
data class RustEventListDto(
|
data class RustEventListDto(
|
||||||
val events: List<RustEventDto> = emptyList(),
|
val events: List<RustEventDto> = emptyList(),
|
||||||
|
val presenceHidden: Boolean = false,
|
||||||
|
val presenceAudience: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +176,20 @@ data class RustLeaderboardRowDto(
|
|||||||
val structures: Int = 0,
|
val structures: Int = 0,
|
||||||
val playtimeSec: Long = 0,
|
val playtimeSec: Long = 0,
|
||||||
val lastSeen: String? = null,
|
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<RustTitleDto> = 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. */
|
/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */
|
||||||
@@ -171,10 +213,20 @@ data class RustWipeDto(
|
|||||||
val lastSeen: String? = null,
|
val lastSeen: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `GET /public/rust/servers/{id}/online` — who is on right now. */
|
/**
|
||||||
|
* `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
|
@Serializable
|
||||||
data class RustOnlineDto(
|
data class RustOnlineDto(
|
||||||
val players: List<RustPresenceDto> = emptyList(),
|
val players: List<RustPresenceDto> = emptyList(),
|
||||||
|
val hidden: Boolean = false,
|
||||||
|
val count: Int = 0,
|
||||||
|
val audience: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* 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<RustMonumentDto>? = 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<RustMapWorldDto>? = null,
|
||||||
|
val events: List<RustMapEventDto>? = null,
|
||||||
|
val players: List<RustMapPlayerDto>? = null,
|
||||||
|
val playersTruncated: Boolean = false,
|
||||||
|
val bases: List<RustMapBaseDto>? = null,
|
||||||
|
val basesTruncated: Boolean = false,
|
||||||
|
val mates: List<RustMapPlayerDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply` or `crate`. */
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
val name: String? = null,
|
||||||
|
val prefab: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
@@ -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<LinkSource> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* 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.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 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<List<RustLinkDto>> =
|
||||||
|
safeApiCall { api.links() }.map { it.links }
|
||||||
|
|
||||||
|
/** Redeem a code from `/link` in game. */
|
||||||
|
suspend fun link(code: String): ApiResult<RustLinkResultDto> =
|
||||||
|
safeApiCall { api.link(RustLinkRequest(code)) }
|
||||||
|
|
||||||
|
/** Release one of the caller's own links. */
|
||||||
|
suspend fun unlink(steamId: String): ApiResult<Boolean> =
|
||||||
|
safeApiCall { api.unlink(steamId) }.map { it.unlinked }
|
||||||
|
|
||||||
|
/** Ranks and grants the site holds for the caller, resolved per server. */
|
||||||
|
suspend fun permissions(): ApiResult<RustPlayerPermissionsDto> =
|
||||||
|
safeApiCall { api.permissions() }
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.core.result.map
|
import com.runicgateway.app.core.result.map
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.RustApi
|
import com.runicgateway.app.data.api.RustApi
|
||||||
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.RustLeaderboardRowDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
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.RustServerDto
|
||||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -50,14 +52,14 @@ class RustRepository @Inject constructor(
|
|||||||
kinds: List<String> = emptyList(),
|
kinds: List<String> = emptyList(),
|
||||||
wipe: String? = null,
|
wipe: String? = null,
|
||||||
limit: Int? = null,
|
limit: Int? = null,
|
||||||
): ApiResult<List<RustEventDto>> = safeApiCall {
|
): ApiResult<RustEventListDto> = safeApiCall {
|
||||||
api.getEvents(
|
api.getEvents(
|
||||||
id = id,
|
id = id,
|
||||||
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||||
limit = limit,
|
limit = limit,
|
||||||
)
|
)
|
||||||
}.map { it.events }
|
}
|
||||||
|
|
||||||
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||||
suspend fun leaderboard(
|
suspend fun leaderboard(
|
||||||
@@ -78,7 +80,20 @@ class RustRepository @Inject constructor(
|
|||||||
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||||
|
|
||||||
/** The presence board. Rows survive an unreachable server, by design. */
|
/**
|
||||||
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
|
* The presence board. Rows survive an unreachable server, by design.
|
||||||
safeApiCall { api.getOnline(id) }.map { it.players }
|
*
|
||||||
|
* 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<RustOnlineDto> =
|
||||||
|
safeApiCall { api.getOnline(id) }
|
||||||
|
|
||||||
|
/** The map's picture, frame and layer gates for this viewer. */
|
||||||
|
suspend fun map(id: String): ApiResult<RustMapDto> =
|
||||||
|
safeApiCall { api.getMap(id) }
|
||||||
|
|
||||||
|
/** What moves on the map, as this viewer may see it. */
|
||||||
|
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
||||||
|
safeApiCall { api.getMapLive(id) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,4 +179,16 @@ object Capability {
|
|||||||
|
|
||||||
/** Core's event system (events Phase 14a). Never a module's. */
|
/** Core's event system (events Phase 14a). Never a module's. */
|
||||||
const val EVENTS = "events"
|
const val EVENTS = "events"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.runicgateway.app.data.api.EventsApi
|
|||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.AdminApi
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
import com.runicgateway.app.data.api.NotificationsApi
|
||||||
|
import com.runicgateway.app.data.api.PlayerRustApi
|
||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
import com.runicgateway.app.data.api.RustApi
|
import com.runicgateway.app.data.api.RustApi
|
||||||
@@ -146,6 +147,15 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
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. */
|
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
|
|||||||
import com.runicgateway.app.ui.shard.RulesScreen
|
import com.runicgateway.app.ui.shard.RulesScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardBoard
|
import com.runicgateway.app.ui.shard.ShardBoard
|
||||||
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
||||||
|
import com.runicgateway.app.ui.rust.RustAccountScreen
|
||||||
import com.runicgateway.app.ui.rust.RustServerScreen
|
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||||
import com.runicgateway.app.ui.rust.RustServersScreen
|
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||||
import com.runicgateway.app.ui.shard.ShardScreen
|
import com.runicgateway.app.ui.shard.ShardScreen
|
||||||
@@ -126,6 +127,8 @@ private val TOP_LEVEL_ROUTES = setOf(
|
|||||||
// screen and is deliberately absent — a back gesture there means "back".
|
// screen and is deliberately absent — a back gesture there means "back".
|
||||||
Routes.RUST,
|
Routes.RUST,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
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,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -632,9 +635,20 @@ private fun RunicNavHost(
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.RUST_SERVER,
|
route = Routes.RUST_SERVER,
|
||||||
arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }),
|
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) })
|
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) {
|
composable(Routes.WIKI) {
|
||||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
@@ -739,6 +753,12 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.PLAYER_HOUSES) {
|
composable(Routes.PLAYER_HOUSES) {
|
||||||
PlayerGate(session, navController) { MyHousesScreen() }
|
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.
|
// ── 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
|
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuAccess.PLAYER,
|
MenuAccess.PLAYER,
|
||||||
capability = Capability.SHARD,
|
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.
|
// 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_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
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).
|
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||||
@@ -257,7 +258,8 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||||
* /rust → RUST (module-rust's server list)
|
* /rust → RUST (module-rust's server list)
|
||||||
* /rust/servers/<id> → RUST_SERVER
|
* /rust/servers/<id>[?tab=<tab>] → RUST_SERVER (a tab the app has; Rust D71)
|
||||||
|
* /player/rust → PLAYER_RUST (Rust D71)
|
||||||
* /site/about → PAGE("about")
|
* /site/about → PAGE("about")
|
||||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||||
* anything else → null, i.e. the Custom Tab
|
* anything else → null, i.e. the Custom Tab
|
||||||
@@ -272,6 +274,12 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* string, any second parameter, and any fragment still hand off** — the carve-out
|
* 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".
|
* 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
|
* 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
|
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||||
* it.
|
* it.
|
||||||
@@ -309,6 +317,10 @@ fun resolveWebPath(path: String?): String? {
|
|||||||
val run = runParam(query) ?: return null
|
val run = runParam(query) ?: return null
|
||||||
return Routes.event(segments[2], run)
|
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
|
if (query.isNotEmpty()) return null
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
@@ -326,6 +338,10 @@ fun resolveWebPath(path: String?): String? {
|
|||||||
// answered by the nav table above, before this fallback is reached.
|
// answered by the nav table above, before this fallback is reached.
|
||||||
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||||
Routes.rustServer(segments[2])
|
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
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,6 +362,17 @@ private fun runParam(query: String): String? {
|
|||||||
return value.takeIf { '&' !in it && '=' !in it }
|
return value.takeIf { '&' !in it && '=' !in it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab a query of **exactly** `tab=<something>` 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.
|
* The module id whose public pages this table maps.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
import com.runicgateway.app.data.repository.ContentRepository
|
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
|
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||||
@@ -84,7 +85,18 @@ object Routes {
|
|||||||
* two can be installed on the same backend, and then both trees exist at once.
|
* two can be installed on the same backend, and then both trees exist at once.
|
||||||
*/
|
*/
|
||||||
const val RUST = "rust"
|
const val RUST = "rust"
|
||||||
const val RUST_SERVER = "rust/servers/{serverId}"
|
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). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
@@ -138,6 +150,7 @@ object Routes {
|
|||||||
const val SERIAL = "serial"
|
const val SERIAL = "serial"
|
||||||
const val RUN = "run"
|
const val RUN = "run"
|
||||||
const val SERVER_ID = "serverId"
|
const val SERVER_ID = "serverId"
|
||||||
|
const val TAB = "tab"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
@@ -168,8 +181,14 @@ object Routes {
|
|||||||
* JVM unit test and throws "not mocked" — this object is pure and every test
|
* 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 builds a route would have to become an instrumented one to keep it
|
||||||
* that way.
|
* 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) = "rust/servers/${encodePathSegment(id)}"
|
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.
|
* Percent-encode one path segment, allowing only the unreserved set.
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ private fun ChannelPrefsList(
|
|||||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
general.forEach { item ->
|
general.forEach { item ->
|
||||||
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode)
|
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
@@ -163,15 +163,7 @@ private fun ChannelPrefsList(
|
|||||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
personal.forEach { item ->
|
personal.forEach { item ->
|
||||||
val selectable = itemSelectable(item, hasLinkedAccount)
|
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||||
ItemRow(
|
|
||||||
item = item,
|
|
||||||
channelsById = channelsById,
|
|
||||||
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
|
|
||||||
enabled = !busy && selectable,
|
|
||||||
pushSupported = pushSupported,
|
|
||||||
onSetMode = onSetMode,
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,33 +173,40 @@ private fun ChannelPrefsList(
|
|||||||
private fun ItemRow(
|
private fun ItemRow(
|
||||||
item: NotificationChannelItemDto,
|
item: NotificationChannelItemDto,
|
||||||
channelsById: Map<String, NotificationChannelDto>,
|
channelsById: Map<String, NotificationChannelDto>,
|
||||||
hint: String?,
|
hasLinkedAccount: Boolean,
|
||||||
enabled: Boolean,
|
busy: Boolean,
|
||||||
pushSupported: Boolean,
|
pushSupported: Boolean,
|
||||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
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)) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = hint ?: item.description,
|
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
// 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.
|
// push carries no push control at all, rather than a dead switch.
|
||||||
item.channels.forEach { channelId ->
|
item.channels.forEach { channelId ->
|
||||||
val channel = channelsById[channelId] ?: return@forEach
|
val channel = channelsById[channelId] ?: return@forEach
|
||||||
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
||||||
|
val mode = item.modes[channelId] ?: channel.defaultMode
|
||||||
ChannelControl(
|
ChannelControl(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
mode = item.modes[channelId] ?: channel.defaultMode,
|
mode = mode,
|
||||||
enabled = enabled,
|
// A held push switch that is already ON stays enabled, because
|
||||||
onSetMode = { mode -> onSetMode(item, channelId, mode) },
|
// switching it off is never refused ([canSetMode]).
|
||||||
|
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
|
||||||
|
onSetMode = { next -> onSetMode(item, channelId, next) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.NotificationChannelItemDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
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.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -53,7 +53,7 @@ const val MODE_OFF = "off"
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NotificationSettingsViewModel @Inject constructor(
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
private val notifications: NotificationsRepository,
|
private val notifications: NotificationsRepository,
|
||||||
private val playerShard: PlayerShardRepository,
|
private val linkedAccounts: LinkedAccountRepository,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
|
val prefs: UiState<NotificationChannelPrefsDto> = 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,
|
val hasLinkedAccount: Boolean = false,
|
||||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||||
val supported: Boolean = true,
|
val supported: Boolean = true,
|
||||||
@@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
_state.update { it.copy(prefs = UiState.Loading) }
|
_state.update { it.copy(prefs = UiState.Loading) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||||
// A linked game account gates the personal streams; failure → treat as none.
|
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
|
||||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
|
||||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
||||||
val current = _state.value
|
val current = _state.value
|
||||||
if (current.busy) return
|
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) }
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
viewModelScope.launch {
|
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
|
* Whether this item's push is held back for want of a linked game account
|
||||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
* (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 =
|
fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||||
!item.requiresLinkedAccount || hasLinkedAccount
|
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))
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* 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<String, String> = emptyMap()
|
||||||
|
private var readAt: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<String>): Map<String, String> = 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.
|
||||||
|
slugs = result.data.entries
|
||||||
|
.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() }
|
||||||
|
.associate { it.runId.toString() to it.slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REFRESH_MS = 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
@@ -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<WorldLine>, val labels: List<GridLabel>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
154
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
154
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.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 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<MapLayer>): List<MapMarker> = 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, hackLeftSec = it.hackLeftSec, hacked = it.hacked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<MapMarker>,
|
||||||
|
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>): 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<String> =
|
||||||
|
live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet()
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<RustReachDto>) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* 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.RustLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||||
|
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)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val links: UiState<List<RustLinkDto>> = UiState.Loading,
|
||||||
|
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
|
||||||
|
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> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(links = UiState.Loading, held = UiState.Loading) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(links = repository.links().toUiState()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(held = repository.permissions().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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,29 @@ fun wipeDay(
|
|||||||
.format(at.atZone(zone))
|
.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.
|
* "3 minutes ago", for a "last reported" line.
|
||||||
*
|
*
|
||||||
@@ -152,3 +175,31 @@ fun shortSteamId(steamId: String?): String {
|
|||||||
*/
|
*/
|
||||||
fun playerLabel(name: String?, steamId: String?): String =
|
fun playerLabel(name: String?, steamId: String?): String =
|
||||||
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
680
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
680
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/*
|
||||||
|
* 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] },
|
||||||
|
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<MapMarker>,
|
||||||
|
selected: MapMarker?,
|
||||||
|
resetKey: String?,
|
||||||
|
onSelect: (MapMarker?) -> Unit,
|
||||||
|
) {
|
||||||
|
val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat())
|
||||||
|
var view by remember { mutableStateOf<ViewSize?>(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<MapTransform?>(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<Pair<GridLabel, TextLayoutResult>> = 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<Pair<GridLabel, TextLayoutResult>>,
|
||||||
|
) {
|
||||||
|
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<MapTransform?, FloatArray>(
|
||||||
|
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)
|
||||||
|
else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5)
|
||||||
|
}
|
||||||
|
MapLayer.EVENTS -> 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?,
|
||||||
|
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)?.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)
|
||||||
|
else -> prefabName(m.kind)
|
||||||
|
}
|
||||||
|
MapLayer.EVENTS -> when (m.kind) {
|
||||||
|
"zone" -> m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_event_zone)
|
||||||
|
"npc" -> stringResource(R.string.rust_map_event_npc)
|
||||||
|
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): 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.
|
||||||
|
MapLayer.EVENTS -> 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<MapLayer>, 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<Color>,
|
||||||
|
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 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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* 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<RustMapDto> = 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> = MapLayer.entries.toSet(),
|
||||||
|
val selected: MapMarker? = null,
|
||||||
|
/** Run id → event slug, for the runs core's public calendar lists. */
|
||||||
|
val eventSlugs: Map<String, String> = 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<RustMapUi> = _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)
|
||||||
|
if (asked != generation) return
|
||||||
|
_state.update { it.copy(eventSlugs = slugs) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.rust
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
@@ -25,6 +29,9 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -33,9 +40,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
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.RustLeaderboardRowDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
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.data.api.dto.RustWipeDto
|
||||||
import com.runicgateway.app.ui.ErrorKind
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
import com.runicgateway.app.ui.PollWhileResumed
|
import com.runicgateway.app.ui.PollWhileResumed
|
||||||
@@ -50,7 +59,8 @@ import com.runicgateway.app.ui.components.ShardCard
|
|||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One Rust server: the feed, the leaderboard, who is on, and the wipes (D13).
|
* 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
|
* **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
|
* makes, and more obviously right on a phone: the four panels are four questions
|
||||||
@@ -64,6 +74,7 @@ import com.runicgateway.app.ui.components.StatusPill
|
|||||||
@Composable
|
@Composable
|
||||||
fun RustServerScreen(
|
fun RustServerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: RustServerViewModel = hiltViewModel(),
|
viewModel: RustServerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -87,7 +98,7 @@ fun RustServerScreen(
|
|||||||
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, modifier)
|
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +128,13 @@ private fun ServerDetail(
|
|||||||
server: RustServerDto,
|
server: RustServerDto,
|
||||||
ui: RustServerUi,
|
ui: RustServerUi,
|
||||||
viewModel: RustServerViewModel,
|
viewModel: RustServerViewModel,
|
||||||
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier.fillMaxSize()) {
|
Column(modifier.fillMaxSize()) {
|
||||||
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||||
|
|
||||||
val tabs = RustTab.entries
|
val tabs = ui.tabs
|
||||||
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||||
tabs.forEach { tab ->
|
tabs.forEach { tab ->
|
||||||
Tab(
|
Tab(
|
||||||
@@ -142,6 +154,7 @@ private fun ServerDetail(
|
|||||||
viewModel::retryLeaderboard,
|
viewModel::retryLeaderboard,
|
||||||
)
|
)
|
||||||
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||||
|
RustTab.MAP -> RustMapPanel(serverOnline = server.online, onOpenEvent = onOpenEvent)
|
||||||
RustTab.WIPES -> WipesPanel(
|
RustTab.WIPES -> WipesPanel(
|
||||||
ui.wipes,
|
ui.wipes,
|
||||||
server.wipeId,
|
server.wipeId,
|
||||||
@@ -157,6 +170,7 @@ private fun tabLabel(tab: RustTab): Int = when (tab) {
|
|||||||
RustTab.FEED -> R.string.rust_tab_feed
|
RustTab.FEED -> R.string.rust_tab_feed
|
||||||
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||||
RustTab.ONLINE -> R.string.rust_tab_online
|
RustTab.ONLINE -> R.string.rust_tab_online
|
||||||
|
RustTab.MAP -> R.string.rust_tab_map
|
||||||
RustTab.WIPES -> R.string.rust_tab_wipes
|
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +193,15 @@ private fun ServerHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextWipeLine(server)?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -252,7 +275,7 @@ private fun WipeFilter(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeedPanel(
|
private fun FeedPanel(
|
||||||
feed: Polled<List<RustEventDto>>,
|
feed: Polled<RustEventListDto>,
|
||||||
filterId: String,
|
filterId: String,
|
||||||
onFilter: (String) -> Unit,
|
onFilter: (String) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -275,7 +298,25 @@ private fun FeedPanel(
|
|||||||
when (val s = feed.state) {
|
when (val s = feed.state) {
|
||||||
is UiState.Loading -> LoadingView()
|
is UiState.Loading -> LoadingView()
|
||||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||||
is UiState.Success -> if (s.data.isEmpty()) {
|
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))
|
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -285,7 +326,8 @@ private fun FeedPanel(
|
|||||||
if (feed.refreshFailed) {
|
if (feed.refreshFailed) {
|
||||||
item { RefreshFailedLine() }
|
item { RefreshFailedLine() }
|
||||||
}
|
}
|
||||||
items(s.data, key = { it.id }) { FeedRow(it) }
|
items(s.data.events, key = { it.id }) { FeedRow(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +399,7 @@ private val RUST_COLUMNS = listOf(
|
|||||||
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LeaderboardPanel(
|
private fun LeaderboardPanel(
|
||||||
state: UiState<List<RustLeaderboardRowDto>>,
|
state: UiState<List<RustLeaderboardRowDto>>,
|
||||||
@@ -417,13 +460,25 @@ private fun LeaderboardPanel(
|
|||||||
// Five numeric columns beside an equal-weight name column
|
// Five numeric columns beside an equal-weight name column
|
||||||
// left "Brannock" touching its own kill count, which the
|
// left "Brannock" touching its own kill count, which the
|
||||||
// walk read as one field.
|
// walk read as one field.
|
||||||
|
Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = playerLabel(row.name, row.steamId),
|
text = playerLabel(row.name, row.steamId),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
|
// 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 ->
|
RUST_COLUMNS.forEach { column ->
|
||||||
Text(
|
Text(
|
||||||
text = column.value(row),
|
text = column.value(row),
|
||||||
@@ -440,18 +495,60 @@ private fun LeaderboardPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ────────────────────────────────────────────────────────────────
|
// ── Online ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnlinePanel(
|
private fun OnlinePanel(
|
||||||
online: Polled<List<RustPresenceDto>>,
|
online: Polled<RustOnlineDto>,
|
||||||
serverOnline: Boolean,
|
serverOnline: Boolean,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (val s = online.state) {
|
when (val s = online.state) {
|
||||||
is UiState.Loading -> LoadingView()
|
is UiState.Loading -> LoadingView()
|
||||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||||
is UiState.Success -> if (s.data.isEmpty()) {
|
// 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(
|
EmptyView(
|
||||||
stringResource(
|
stringResource(
|
||||||
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||||
@@ -479,7 +576,7 @@ private fun OnlinePanel(
|
|||||||
if (online.refreshFailed) {
|
if (online.refreshFailed) {
|
||||||
item { RefreshFailedLine() }
|
item { RefreshFailedLine() }
|
||||||
}
|
}
|
||||||
items(s.data, key = { it.steamId }) { player ->
|
items(s.data.players, key = { it.steamId }) { player ->
|
||||||
ShardCard(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(16.dp),
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ package com.runicgateway.app.ui.rust
|
|||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
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.RustLeaderboardRowDto
|
||||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
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.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.Polled
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.navigation.Routes
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
@@ -25,8 +28,25 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** The four sections of a server's page (D13). */
|
/**
|
||||||
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES }
|
* 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. */
|
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||||
object RustSort {
|
object RustSort {
|
||||||
@@ -52,11 +72,18 @@ data class RustServerUi(
|
|||||||
val sort: String = RustSort.KILLS,
|
val sort: String = RustSort.KILLS,
|
||||||
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||||
val selectedWipe: String? = null,
|
val selectedWipe: String? = null,
|
||||||
val feed: Polled<List<RustEventDto>> = Polled(),
|
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
|
||||||
val online: Polled<List<RustPresenceDto>> = Polled(),
|
val feed: Polled<RustEventListDto> = Polled(),
|
||||||
|
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
|
||||||
|
val online: Polled<RustOnlineDto> = Polled(),
|
||||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||||
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
val wipes: UiState<List<RustWipeDto>> = 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<RustTab> get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
||||||
@@ -84,16 +111,34 @@ data class RustServerUi(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RustServerViewModel @Inject constructor(
|
class RustServerViewModel @Inject constructor(
|
||||||
private val repository: RustRepository,
|
private val repository: RustRepository,
|
||||||
|
capabilities: SiteCapabilitiesRepository,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
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))
|
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||||
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
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. */
|
/** A first load or a retry of the whole page. */
|
||||||
@@ -118,7 +163,8 @@ class RustServerViewModel @Inject constructor(
|
|||||||
when (_state.value.tab) {
|
when (_state.value.tab) {
|
||||||
RustTab.FEED -> askFeed()
|
RustTab.FEED -> askFeed()
|
||||||
RustTab.ONLINE -> askOnline()
|
RustTab.ONLINE -> askOnline()
|
||||||
RustTab.LEADERBOARD, RustTab.WIPES -> Unit
|
// The map keeps its own cadence (D124) in [RustMapViewModel].
|
||||||
|
RustTab.LEADERBOARD, RustTab.WIPES, RustTab.MAP -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +179,7 @@ class RustServerViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun selectTab(tab: RustTab) {
|
fun selectTab(tab: RustTab) {
|
||||||
val already = _state.value
|
val already = _state.value
|
||||||
|
if (tab !in already.tabs) return
|
||||||
_state.update { it.copy(tab = tab) }
|
_state.update { it.copy(tab = tab) }
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -141,6 +188,8 @@ class RustServerViewModel @Inject constructor(
|
|||||||
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
||||||
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
||||||
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
||||||
|
// Loaded by its own view model when the tab composes.
|
||||||
|
RustTab.MAP -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextWipeLine(server)?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = lastReported(server),
|
text = lastReported(server),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -170,6 +179,22 @@ internal fun describeWorld(server: RustServerDto): String? {
|
|||||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
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".
|
* "last reported 3 minutes ago".
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -490,7 +490,7 @@
|
|||||||
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
|
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
|
||||||
<string name="notifications_section_general">General</string>
|
<string name="notifications_section_general">General</string>
|
||||||
<string name="notifications_section_personal">Your game account</string>
|
<string name="notifications_section_personal">Your game account</string>
|
||||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
<string name="notifications_requires_link">Link a game account to get this as a push notification.</string>
|
||||||
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
||||||
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
||||||
<string name="notifications_saved">Notification settings saved.</string>
|
<string name="notifications_saved">Notification settings saved.</string>
|
||||||
@@ -578,6 +578,8 @@
|
|||||||
<string name="rust_world_size">size %1$d</string>
|
<string name="rust_world_size">size %1$d</string>
|
||||||
<string name="rust_world_seed">seed %1$d</string>
|
<string name="rust_world_seed">seed %1$d</string>
|
||||||
<string name="rust_wiped_on">wiped %1$s</string>
|
<string name="rust_wiped_on">wiped %1$s</string>
|
||||||
|
<string name="rust_next_wipe">Next wipe %1$s</string>
|
||||||
|
<string name="rust_next_wipe_rescheduled">Next wipe %1$s (rescheduled)</string>
|
||||||
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
||||||
<string name="rust_no_such_server">No such server</string>
|
<string name="rust_no_such_server">No such server</string>
|
||||||
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
||||||
@@ -586,6 +588,55 @@
|
|||||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||||
<string name="rust_tab_online">Online</string>
|
<string name="rust_tab_online">Online</string>
|
||||||
<string name="rust_tab_wipes">Wipes</string>
|
<string name="rust_tab_wipes">Wipes</string>
|
||||||
|
<string name="rust_tab_map">Map</string>
|
||||||
|
<!-- The Map tab (Rust phase 15, D121–D125). The wording follows the web's Map
|
||||||
|
tab, so a reader moving between the two reads the same sentences. -->
|
||||||
|
<string name="rust_map_none">No map yet. This server has not told the site which map it is on. The map will appear once the server is up.</string>
|
||||||
|
<string name="rust_map_no_picture">This server has no picture of its map, so the layers are drawn on a plain background.</string>
|
||||||
|
<string name="rust_map_picture_failed">The picture of the map could not be loaded, so the layers are drawn on a plain background.</string>
|
||||||
|
<string name="rust_map_description">Map of the server</string>
|
||||||
|
<string name="rust_map_status_nothing_live">No moving layers are shown to you on this server.</string>
|
||||||
|
<string name="rust_map_status_asking">Asking the server where things are…</string>
|
||||||
|
<string name="rust_map_status_not_now">The server did not say where things are just now. The site asks again every ten seconds.</string>
|
||||||
|
<string name="rust_map_status_offline">The server is offline, so nothing is moving on its map.</string>
|
||||||
|
<string name="rust_map_status_failed">Positions could not be loaded.</string>
|
||||||
|
<string name="rust_map_status_failed_kept">Could not refresh just now. These are the last positions the site heard.</string>
|
||||||
|
<string name="rust_map_status_as_of">Positions as of %1$s. They refresh every ten seconds while this tab is open.</string>
|
||||||
|
<string name="rust_map_layer_grid">Grid</string>
|
||||||
|
<string name="rust_map_layer_world">Monuments & world events</string>
|
||||||
|
<string name="rust_map_layer_events">Site events</string>
|
||||||
|
<string name="rust_map_layer_players">Players</string>
|
||||||
|
<string name="rust_map_layer_bases">Bases</string>
|
||||||
|
<string name="rust_map_layer_mates">You and your clan</string>
|
||||||
|
<string name="rust_map_hidden_signin">Sign in to see this layer.</string>
|
||||||
|
<string name="rust_map_hidden_public">Shown to everyone.</string>
|
||||||
|
<string name="rust_map_hidden_staff">Shown to staff only.</string>
|
||||||
|
<string name="rust_map_capped">%1$s Limited by who may see who is online.</string>
|
||||||
|
<string name="rust_map_players_truncated">Not every sleeper is shown.</string>
|
||||||
|
<string name="rust_map_bases_truncated">Not every base is shown.</string>
|
||||||
|
<string name="rust_map_mates_note">Your own position, and clan mates who are online.</string>
|
||||||
|
<string name="rust_map_mates_link">Link your Steam account to see yourself and your clan here.</string>
|
||||||
|
<string name="rust_map_cargo">Cargo ship</string>
|
||||||
|
<string name="rust_map_heli">Patrol helicopter</string>
|
||||||
|
<string name="rust_map_chinook">Chinook</string>
|
||||||
|
<string name="rust_map_bradley">Bradley APC</string>
|
||||||
|
<string name="rust_map_supply">Supply drop</string>
|
||||||
|
<string name="rust_map_crate">Locked crate</string>
|
||||||
|
<string name="rust_map_crate_hack">%1$s left on the hack</string>
|
||||||
|
<string name="rust_map_crate_hacked">Hacked</string>
|
||||||
|
<string name="rust_map_event_zone">Event zone</string>
|
||||||
|
<string name="rust_map_event_crate">Event crate</string>
|
||||||
|
<string name="rust_map_event_npc">Event NPC</string>
|
||||||
|
<string name="rust_map_site_event">Site event</string>
|
||||||
|
<string name="rust_map_open_event">Open event</string>
|
||||||
|
<string name="rust_map_tc">Tool cupboard</string>
|
||||||
|
<string name="rust_map_vending">Vending machine</string>
|
||||||
|
<string name="rust_map_player_sleeping">Sleeping</string>
|
||||||
|
<string name="rust_map_player_offline">Asleep, offline</string>
|
||||||
|
<string name="rust_map_you">You</string>
|
||||||
|
<string name="rust_map_clan_mate">Clan mate</string>
|
||||||
|
<string name="rust_map_grid">Grid %1$s</string>
|
||||||
|
<string name="rust_map_close">Close</string>
|
||||||
<string name="rust_all_time">All time</string>
|
<string name="rust_all_time">All time</string>
|
||||||
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||||
<string name="rust_wipe_this_one">Current</string>
|
<string name="rust_wipe_this_one">Current</string>
|
||||||
@@ -593,6 +644,17 @@
|
|||||||
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||||
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||||
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||||
|
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
|
||||||
|
operator's audience the server sends a count and no names. -->
|
||||||
|
<plurals name="rust_online_hidden_count">
|
||||||
|
<item quantity="one">%1$d player online</item>
|
||||||
|
<item quantity="other">%1$d players online</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="rust_online_hidden_staff">Only this site’s staff can see who they are.</string>
|
||||||
|
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
|
||||||
|
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
|
||||||
|
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this site’s staff can see what players did.</string>
|
||||||
|
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
|
||||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||||
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||||
<string name="rust_presence_live">On the server right now.</string>
|
<string name="rust_presence_live">On the server right now.</string>
|
||||||
@@ -605,4 +667,30 @@
|
|||||||
<string name="rust_col_structures">Built</string>
|
<string name="rust_col_structures">Built</string>
|
||||||
<string name="rust_col_played">Played</string>
|
<string name="rust_col_played">Played</string>
|
||||||
<string name="rust_online_badge">%1$d players online</string>
|
<string name="rust_online_badge">%1$d players online</string>
|
||||||
|
|
||||||
|
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
|
||||||
|
<string name="menu_rust_account">My Rust account</string>
|
||||||
|
<string name="rust_link_title">Link your Steam account</string>
|
||||||
|
<string name="rust_link_hint">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.</string>
|
||||||
|
<string name="rust_link_code">Link code</string>
|
||||||
|
<string name="rust_link_action">Link account</string>
|
||||||
|
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
|
||||||
|
<string name="rust_link_already">That account was already linked to you.</string>
|
||||||
|
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
|
||||||
|
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
|
||||||
|
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
|
||||||
|
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
|
||||||
|
<string name="rust_link_error">Could not link that code.</string>
|
||||||
|
<string name="rust_unlink_action">Unlink</string>
|
||||||
|
<string name="rust_unlink_error">Could not unlink that account.</string>
|
||||||
|
<string name="rust_account_linked_when">linked %1$s</string>
|
||||||
|
<string name="rust_account_none">No Steam account is linked to this profile yet.</string>
|
||||||
|
<string name="rust_account_fleet_note">A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game.</string>
|
||||||
|
<string name="rust_held_title">What you can do in game</string>
|
||||||
|
<string name="rust_held_empty">Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.</string>
|
||||||
|
<string name="rust_held_unlinked">None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.</string>
|
||||||
|
<string name="rust_held_waiting_note">A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.</string>
|
||||||
|
<string name="rust_held_no_servers">No servers are configured yet.</string>
|
||||||
|
<string name="rust_reach_live">%1$s · has it</string>
|
||||||
|
<string name="rust_reach_waiting">%1$s · waiting</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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<Long?>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RustOnlineDto>(
|
||||||
|
"""{"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<RustOnlineDto>(
|
||||||
|
"""{"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<RustOnlineDto>("""{"players":[]}""")
|
||||||
|
assertFalse(dto.hidden)
|
||||||
|
assertNull(dto.audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aWithheldFeedSaysSo() {
|
||||||
|
val dto = json.decodeFromString<RustEventListDto>(
|
||||||
|
"""{"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,13 @@ class FakeEventsApi : EventsApi {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
|
/** How many times the calendar was read, for the Rust map's run resolver. */
|
||||||
reply(calendar)
|
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 {
|
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
|
||||||
lastSlug = slug
|
lastSlug = slug
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ package com.runicgateway.app.data.api.fake
|
|||||||
import com.runicgateway.app.data.api.RustApi
|
import com.runicgateway.app.data.api.RustApi
|
||||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
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.RustOnlineDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||||
@@ -31,12 +33,23 @@ class FakeRustApi : RustApi {
|
|||||||
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
|
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
|
||||||
var wipes: RustWipeListDto = RustWipeListDto()
|
var wipes: RustWipeListDto = RustWipeListDto()
|
||||||
var online: RustOnlineDto = RustOnlineDto()
|
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<suspend () -> RustMapLiveDto> = ArrayDeque()
|
||||||
|
|
||||||
var serversCalls: Int = 0
|
var serversCalls: Int = 0
|
||||||
var eventCalls: Int = 0
|
var eventCalls: Int = 0
|
||||||
var leaderboardCalls: Int = 0
|
var leaderboardCalls: Int = 0
|
||||||
var onlineCalls: Int = 0
|
var onlineCalls: Int = 0
|
||||||
var wipeCalls: 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. */
|
/** The `kind` the last feed read carried — null means it sent none at all. */
|
||||||
var lastKind: String? = null
|
var lastKind: String? = null
|
||||||
@@ -94,4 +107,17 @@ class FakeRustApi : RustApi {
|
|||||||
lastId = id
|
lastId = id
|
||||||
return reply(online)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LinkSource>(), linkSourcesFor(SiteCapabilities(setOf("events"), emptySet())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,13 +123,27 @@ class MenuCapabilityGatingTest {
|
|||||||
// rather than by the visibility framework. They rendered on a backend with
|
// rather than by the visibility framework. They rendered on a backend with
|
||||||
// no module installed and answered "This content couldn't be found",
|
// no module installed and answered "This content couldn't be found",
|
||||||
// through a green suite.
|
// 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 {
|
val onAModulePath = APP_MENU.filter {
|
||||||
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
||||||
}
|
}
|
||||||
assertEquals(8, onAModulePath.size)
|
assertEquals(9, onAModulePath.size)
|
||||||
assertTrue(
|
|
||||||
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(),
|
val expected = onAModulePath.associate { entry ->
|
||||||
onAModulePath.all { it.capability == Capability.SHARD },
|
entry.route to if (entry.route.startsWith("player/rust")) {
|
||||||
|
Capability.RUST
|
||||||
|
} else {
|
||||||
|
Capability.SHARD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expected,
|
||||||
|
onAModulePath.associate { it.route to it.capability },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.navigation
|
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.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionUser
|
||||||
import com.runicgateway.app.data.repository.Capability
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
|
import com.runicgateway.app.ui.rust.RustTab
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -102,6 +105,43 @@ class RustNavigationTest {
|
|||||||
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
|
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() {
|
@Test fun aRustPathWithAQueryHandsOff() {
|
||||||
// The website keeps tab, filter, wipe and sort in the URL; the app keeps
|
// 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
|
// them in a view model. Resolving `?tab=wipes` natively would silently drop
|
||||||
@@ -109,4 +149,38 @@ class RustNavigationTest {
|
|||||||
assertNull(resolveWebPath("/rust?tab=wipes"))
|
assertNull(resolveWebPath("/rust?tab=wipes"))
|
||||||
assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import org.junit.Test
|
|||||||
/**
|
/**
|
||||||
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
* 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,
|
* §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 {
|
class NotificationRoutingTest {
|
||||||
|
|
||||||
@@ -33,15 +34,23 @@ class NotificationRoutingTest {
|
|||||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun personalItemNeedsLinkedAccount() {
|
@Test fun personalItemNeedsALinkOnlyToSwitchPushOn() {
|
||||||
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
val personal = NotificationChannelItemDto(
|
||||||
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
|
id = "vendor.sale", personal = true, requiresLinkedAccount = true,
|
||||||
assertTrue(itemSelectable(personal, hasLinkedAccount = 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() {
|
@Test fun generalItemIsNeverHeldBack() {
|
||||||
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
val general = NotificationChannelItemDto(
|
||||||
assertTrue(itemSelectable(general, hasLinkedAccount = false))
|
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) ───────────────
|
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
|
||||||
|
|||||||
@@ -64,10 +64,36 @@ class NotificationSettingsViewModelTest {
|
|||||||
assertEquals(listOf("off", "instant"), push.modes)
|
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 }
|
val personal = prefs().items.first { it.personal }
|
||||||
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false))
|
assertEquals(false, pushNeedsLink(personal, hasLinkedAccount = false))
|
||||||
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true))
|
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 {
|
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* 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<String, String>(), 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<String, String>(), 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 `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)
|
||||||
|
}
|
||||||
|
}
|
||||||
213
app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt
Normal file
213
app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt
Normal file
141
app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* 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<String>(), eventRunIds(RustMapLiveDto()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.rust
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
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 com.runicgateway.app.data.api.dto.RustServerListDto
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -102,4 +103,47 @@ class RustDtoTest {
|
|||||||
assertNull(event.str("name"))
|
assertNull(event.str("name"))
|
||||||
assertEquals(FeedTone.SERVER, describe(event).tone)
|
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<RustServerListDto>(
|
||||||
|
"""{"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<RustLeaderboardDto>(
|
||||||
|
"""
|
||||||
|
{"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<RustLeaderboardDto>(
|
||||||
|
"""{"leaderboard":[{"steamId":"1","name":"Brannock","kills":12}]}""",
|
||||||
|
)
|
||||||
|
assertTrue(older.leaderboard.single().titles.isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.rust
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -131,4 +132,41 @@ class RustFormatTest {
|
|||||||
assertEquals("1234", shortSteamId("1234"))
|
assertEquals("1234", shortSteamId("1234"))
|
||||||
assertEquals("", shortSteamId(null))
|
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")!!))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt
Normal file
106
app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* 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.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<RustMapLiveDto>("""{"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<MapMarker>(), mapMarkers(RustMapDto(), live, MapLayer.entries.toSet()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty layer is present, and different from an absent one`() {
|
||||||
|
val live = json.decodeFromString<RustMapLiveDto>("""{"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<RustMapLiveDto>("""{"live":false,"reason":"timeout"}""")
|
||||||
|
assertFalse(live.live)
|
||||||
|
assertEquals("timeout", live.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the map as the rig answers it`() {
|
||||||
|
val map = json.decodeFromString<RustMapDto>(
|
||||||
|
"""
|
||||||
|
{"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<RustMapDto>("""{"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<RustMapLiveDto>(
|
||||||
|
"""{"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<EventCalendarDto>(
|
||||||
|
"""{"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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Unit>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
package com.runicgateway.app.ui.rust
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
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.RustLeaderboardDto
|
||||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
@@ -12,10 +15,15 @@ import com.runicgateway.app.data.api.dto.RustServerDto
|
|||||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
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.api.fake.FakeRustApi
|
||||||
import com.runicgateway.app.data.repository.RustRepository
|
import com.runicgateway.app.data.repository.RustRepository
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -35,9 +43,13 @@ class RustServerViewModelTest {
|
|||||||
private val api = FakeRustApi()
|
private val api = FakeRustApi()
|
||||||
private val repository = RustRepository(api)
|
private val repository = RustRepository(api)
|
||||||
|
|
||||||
private fun viewModel(id: String = "main") = RustServerViewModel(
|
private val publicApi = FakePublicApi()
|
||||||
|
private val capabilities = SiteCapabilitiesRepository(publicApi)
|
||||||
|
|
||||||
|
private fun viewModel(id: String = "main", tab: String? = null) = RustServerViewModel(
|
||||||
repository,
|
repository,
|
||||||
SavedStateHandle(mapOf("serverId" to id)),
|
capabilities,
|
||||||
|
SavedStateHandle(mapOf("serverId" to id, "tab" to tab)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -100,6 +112,35 @@ class RustServerViewModelTest {
|
|||||||
assertEquals(onlineBefore + 1, api.onlineCalls)
|
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
|
@Test
|
||||||
fun `a poll on a still panel asks for nothing but the server line`() {
|
fun `a poll on a still panel asks for nothing but the server line`() {
|
||||||
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))
|
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))
|
||||||
@@ -190,4 +231,77 @@ class RustServerViewModelTest {
|
|||||||
|
|
||||||
assertEquals("playtime", api.lastSort)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user