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
|
||||
|
||||
# `packages: ''` is load-bearing, not tidying. The action's own default is
|
||||
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
|
||||
# default makes `sdkmanager tools` exit 1 and the step fails before a line
|
||||
# of this repo is compiled. It is redundant here regardless: the next step
|
||||
# installs exactly what the build targets.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
# Install exactly what the build targets so it never depends on AGP's
|
||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||
|
||||
@@ -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 com.runicgateway.app.MainActivity
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
@@ -20,10 +21,18 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds the notification channels and posts a notification for a received tickle
|
||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
|
||||
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
|
||||
* carries nothing to render, so nothing is fetched to display the notification; tapping
|
||||
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
|
||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity],
|
||||
* which fetches fresh over the authenticated API.
|
||||
*
|
||||
* **The title comes from the inbox row when there is one** (`docs/modules/rust/PLAN.md`
|
||||
* D70). The tickle carries nothing to render. When its ref names an inbox row,
|
||||
* [PushContentResolver] has already pulled that row over the authenticated API, and
|
||||
* the notification says what the row says. Every other tickle keeps the M7 behaviour:
|
||||
* a **generic per-stream** title from the fixed [PushStreams] catalog. Until D70 that
|
||||
* was every tickle, and "New notification" is all a Rust raid alert ever said.
|
||||
*
|
||||
* **The lock screen shows only the generic title.** The row's text is content, and
|
||||
* a locked phone on a table is not the place for "a door was destroyed in S16".
|
||||
*/
|
||||
@Singleton
|
||||
class PushNotifier @Inject constructor(
|
||||
@@ -65,17 +74,32 @@ class PushNotifier @Inject constructor(
|
||||
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
||||
.build()
|
||||
|
||||
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
|
||||
fun notify(tickle: PushTickle) {
|
||||
/**
|
||||
* Post a notification for a tickle, deep-linking to the stream's screen on tap.
|
||||
*
|
||||
* [item] is the inbox row the tickle points at, when it could be pulled; null
|
||||
* falls back to the per-stream title.
|
||||
*/
|
||||
fun notify(tickle: PushTickle, item: NotificationItemDto? = null) {
|
||||
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
||||
val title = context.getString(PushStreams.titleRes(tickle.stream))
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||
.setContentTitle(title)
|
||||
val generic = context.getString(PushStreams.titleRes(tickle.stream))
|
||||
val content = notificationText(item, generic)
|
||||
val redacted = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||
.setContentTitle(generic)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.build()
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||
.setContentTitle(content.title)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(redacted)
|
||||
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
||||
.build()
|
||||
content.body?.let { body ->
|
||||
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
}
|
||||
val notification = builder.build()
|
||||
try {
|
||||
manager.notify(nextId.getAndIncrement(), notification)
|
||||
} catch (_: SecurityException) {
|
||||
@@ -108,3 +132,18 @@ class PushNotifier @Inject constructor(
|
||||
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
||||
}
|
||||
}
|
||||
|
||||
/** What a posted notification says: a title always, and a body when the row had one. */
|
||||
internal data class NotificationText(val title: String, val body: String?)
|
||||
|
||||
/**
|
||||
* The text for a notification about [item], falling back to [generic] (D70).
|
||||
*
|
||||
* A row with a blank title is treated as no row: a notification with an empty
|
||||
* headline is worse than one that says only that something happened.
|
||||
*/
|
||||
internal fun notificationText(item: NotificationItemDto?, generic: String): NotificationText {
|
||||
val title = item?.title?.trim().orEmpty()
|
||||
if (title.isEmpty()) return NotificationText(generic, null)
|
||||
return NotificationText(title, item?.body?.trim()?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,6 +32,7 @@ class PushService : Service() {
|
||||
|
||||
@Inject lateinit var streamClient: NtfyStreamClient
|
||||
@Inject lateinit var notifier: PushNotifier
|
||||
@Inject lateinit var content: PushContentResolver
|
||||
@Inject lateinit var prefs: PushPreferences
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -67,8 +67,15 @@ class PushService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
|
||||
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
|
||||
// Each tickle in its own job (D70). Titling one from its inbox row
|
||||
// suspends on a network read, and inside `collectLatest` the next tickle
|
||||
// would cancel it. A burst (two owners' raid alerts, then a restart) would
|
||||
// then post only the last.
|
||||
streamClient.events(snapshot.ntfyUrl, topic).collect { event ->
|
||||
if (event is NtfyStreamClient.Event.Message) {
|
||||
val tickle = event.tickle
|
||||
scope.launch { notifier.notify(tickle, content.itemFor(tickle)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
@@ -91,4 +93,19 @@ interface RustApi {
|
||||
/** The presence board, which an unreachable server does not clear. */
|
||||
@GET("api/v1/public/rust/servers/{id}/online")
|
||||
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
||||
|
||||
/**
|
||||
* The map: where its picture is, the frame to draw it in, and which layers
|
||||
* this viewer gets (Rust phase 14). A module older than phase 14 answers 404.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map")
|
||||
suspend fun getMap(@Path("id") id: String): RustMapDto
|
||||
|
||||
/**
|
||||
* What moves, already cut down to this viewer on the server: a layer they
|
||||
* may not see is absent. The module asks the game at most once per five
|
||||
* seconds per server, however many viewers there are (D111).
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map/live")
|
||||
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
|
||||
}
|
||||
|
||||
@@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||
*
|
||||
* [runId] is on a run and never on a projection, which has nothing committed to
|
||||
* it. Core added it for Rust D125, so a map marker naming a run can find its
|
||||
* event; a core older than that omits it, and the marker stays unlinked.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventCalendarEntryDto(
|
||||
val kind: String = "run",
|
||||
val runId: Long? = null,
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val seriesName: String? = null,
|
||||
|
||||
@@ -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. */
|
||||
val updatedAt: String? = null,
|
||||
val stale: Boolean = false,
|
||||
/**
|
||||
* When the server wipes next (module-rust phase 16, D130), or null. Null
|
||||
* both when the operator set no schedule and against a module older than
|
||||
* phase 16, which does not send the field — either way nothing is drawn.
|
||||
*/
|
||||
val nextWipe: RustNextWipeDto? = null,
|
||||
)
|
||||
|
||||
/** `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
|
||||
data class RustEventListDto(
|
||||
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 playtimeSec: Long = 0,
|
||||
val lastSeen: String? = null,
|
||||
/**
|
||||
* The chat titles this player holds now (module-rust phase 17, M19) — the
|
||||
* ones the game shows in chat, after the server's mode. They rank the
|
||||
* CURRENT wipe whichever wipe the board is showing. Absent from an older
|
||||
* module, which is why it defaults to empty: nothing is drawn.
|
||||
*/
|
||||
val titles: List<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. */
|
||||
@@ -171,10 +213,20 @@ data class RustWipeDto(
|
||||
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
|
||||
data class RustOnlineDto(
|
||||
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.safeApiCall
|
||||
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.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.RustWipeDto
|
||||
import javax.inject.Inject
|
||||
@@ -50,14 +52,14 @@ class RustRepository @Inject constructor(
|
||||
kinds: List<String> = emptyList(),
|
||||
wipe: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustEventDto>> = safeApiCall {
|
||||
): ApiResult<RustEventListDto> = safeApiCall {
|
||||
api.getEvents(
|
||||
id = id,
|
||||
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.events }
|
||||
}
|
||||
|
||||
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||
suspend fun leaderboard(
|
||||
@@ -78,7 +80,20 @@ class RustRepository @Inject constructor(
|
||||
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||
|
||||
/** The presence board. Rows survive an unreachable server, by design. */
|
||||
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
|
||||
safeApiCall { api.getOnline(id) }.map { it.players }
|
||||
/**
|
||||
* The presence board. Rows survive an unreachable server, by design.
|
||||
*
|
||||
* Answered whole rather than as its rows: `hidden` and `count` are what let
|
||||
* the screen tell "withheld from you" from "nobody is on".
|
||||
*/
|
||||
suspend fun online(id: String): ApiResult<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. */
|
||||
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.AdminApi
|
||||
import com.runicgateway.app.data.api.NotificationsApi
|
||||
import com.runicgateway.app.data.api.PlayerRustApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
@@ -146,6 +147,15 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
||||
|
||||
/**
|
||||
* A player's own Rust identity and entitlements (§9 M15) — bearer-authed on
|
||||
* the main client, like [providePlayerShardApi] one game along.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi =
|
||||
retrofit.create(PlayerRustApi::class.java)
|
||||
|
||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@@ -103,6 +103,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
|
||||
import com.runicgateway.app.ui.shard.RulesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
||||
import com.runicgateway.app.ui.rust.RustAccountScreen
|
||||
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
@@ -126,6 +127,8 @@ private val TOP_LEVEL_ROUTES = setOf(
|
||||
// screen and is deliberately absent — a back gesture there means "back".
|
||||
Routes.RUST,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
// The player's own Rust account (M15) — a drawer row like the three above it.
|
||||
Routes.PLAYER_RUST,
|
||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||
)
|
||||
|
||||
@@ -632,9 +635,20 @@ private fun RunicNavHost(
|
||||
}
|
||||
composable(
|
||||
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) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
@@ -739,6 +753,12 @@ private fun RunicNavHost(
|
||||
composable(Routes.PLAYER_HOUSES) {
|
||||
PlayerGate(session, navController) { MyHousesScreen() }
|
||||
}
|
||||
// The player's own Rust account (M15). Behind the same gate as the three
|
||||
// above: the route is `requireAuth` server-side, and the gate is here so a
|
||||
// signed-out reader is sent home rather than left on a screen that 401s.
|
||||
composable(Routes.PLAYER_RUST) {
|
||||
PlayerGate(session, navController) { RustAccountScreen() }
|
||||
}
|
||||
|
||||
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
|
||||
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||
|
||||
@@ -177,6 +177,22 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuAccess.PLAYER,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// The player's own Rust account (M15). Beside the three UO rows above and
|
||||
// gated exactly as they are: the module's `rust` capability (a row needs the
|
||||
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
|
||||
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
|
||||
//
|
||||
// **`rust`, not `identity`.** The module declares a surface word for each of
|
||||
// its features, and D16's rule is that a capability answers one question —
|
||||
// *is the module there* — so a surface word is not what a row hangs on. The
|
||||
// paths these screens call are knowledge the app has from reading the
|
||||
// module's router, exactly as §2.9 requires.
|
||||
MenuEntry(
|
||||
Routes.PLAYER_RUST,
|
||||
R.string.menu_rust_account,
|
||||
MenuAccess.PLAYER,
|
||||
capability = Capability.RUST,
|
||||
),
|
||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
import com.runicgateway.app.ui.rust.RustTab
|
||||
|
||||
/**
|
||||
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||
@@ -257,7 +258,8 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /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")
|
||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||
* 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
|
||||
* is one key on one path, not a general "parse the query".
|
||||
*
|
||||
* **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path,
|
||||
* which is what the *new leader* notice links to. It resolves only when the value
|
||||
* is a tab the app has. `?tab=clans` still hands off, because the website has a
|
||||
* Clans tab and the app does not, and opening the feed instead would be the quiet
|
||||
* drop this rule exists to prevent.
|
||||
*
|
||||
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
||||
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||
* it.
|
||||
@@ -309,6 +317,10 @@ fun resolveWebPath(path: String?): String? {
|
||||
val run = runParam(query) ?: return null
|
||||
return Routes.event(segments[2], run)
|
||||
}
|
||||
if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) {
|
||||
val tab = rustTabParam(query) ?: return null
|
||||
return Routes.rustServer(segments[2], tab)
|
||||
}
|
||||
if (query.isNotEmpty()) return null
|
||||
|
||||
return when {
|
||||
@@ -326,6 +338,10 @@ fun resolveWebPath(path: String?): String? {
|
||||
// answered by the nav table above, before this fallback is reached.
|
||||
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||
Routes.rustServer(segments[2])
|
||||
// The player's own Rust account, which the *account linked* notice links
|
||||
// to (D71). Not in the nav table above, which is built from the PUBLIC
|
||||
// nav; a signed-out tap is caught by the player gate like any other.
|
||||
segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -346,6 +362,17 @@ private fun runParam(query: String): String? {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.ui.rust.RustTab
|
||||
|
||||
/**
|
||||
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||
@@ -84,7 +85,18 @@ object Routes {
|
||||
* two can be installed on the same backend, and then both trees exist at once.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
const val RUST_SERVER = "rust/servers/{serverId}"
|
||||
const val RUST_SERVER = "rust/servers/{serverId}?tab={tab}"
|
||||
|
||||
/**
|
||||
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
|
||||
* what the site has given them in game.
|
||||
*
|
||||
* Under `player/` with the three UO rows rather than under `rust/` with the
|
||||
* two public ones, because the grouping that matters to a reader is *whose
|
||||
* data is this*: these are the signed-in, self-scoped screens. The website
|
||||
* agrees — it serves this from the player tier, at `/player/rust`.
|
||||
*/
|
||||
const val PLAYER_RUST = "player/rust"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
@@ -138,6 +150,7 @@ object Routes {
|
||||
const val SERIAL = "serial"
|
||||
const val RUN = "run"
|
||||
const val SERVER_ID = "serverId"
|
||||
const val TAB = "tab"
|
||||
}
|
||||
|
||||
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
|
||||
* that builds a route would have to become an instrumented one to keep it
|
||||
* that way.
|
||||
*
|
||||
* [tab] is what the *new leader* notice's link carries (Rust D71), and it is
|
||||
* dropped when absent, as `run` is on [event].
|
||||
*/
|
||||
fun rustServer(id: String) = "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.
|
||||
|
||||
@@ -153,7 +153,7 @@ private fun ChannelPrefsList(
|
||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
general.forEach { item ->
|
||||
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode)
|
||||
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||
HorizontalDivider()
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
@@ -163,15 +163,7 @@ private fun ChannelPrefsList(
|
||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
personal.forEach { item ->
|
||||
val selectable = itemSelectable(item, hasLinkedAccount)
|
||||
ItemRow(
|
||||
item = item,
|
||||
channelsById = channelsById,
|
||||
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
|
||||
enabled = !busy && selectable,
|
||||
pushSupported = pushSupported,
|
||||
onSetMode = onSetMode,
|
||||
)
|
||||
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
@@ -181,33 +173,40 @@ private fun ChannelPrefsList(
|
||||
private fun ItemRow(
|
||||
item: NotificationChannelItemDto,
|
||||
channelsById: Map<String, NotificationChannelDto>,
|
||||
hint: String?,
|
||||
enabled: Boolean,
|
||||
hasLinkedAccount: Boolean,
|
||||
busy: Boolean,
|
||||
pushSupported: Boolean,
|
||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||
) {
|
||||
// The link holds back one control, push switching on, so the row stays live
|
||||
// and the hint names that one control (Rust D69). Where this device has no
|
||||
// push at all, there is no control to hold back and nothing to explain.
|
||||
val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount)
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = hint ?: item.description,
|
||||
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
||||
fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal,
|
||||
)
|
||||
// The item's OWN channel list, in the registry's order. An id nothing can
|
||||
// push carries no push control at all, rather than a dead switch.
|
||||
item.channels.forEach { channelId ->
|
||||
val channel = channelsById[channelId] ?: return@forEach
|
||||
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
||||
val mode = item.modes[channelId] ?: channel.defaultMode
|
||||
ChannelControl(
|
||||
channel = channel,
|
||||
mode = item.modes[channelId] ?: channel.defaultMode,
|
||||
enabled = enabled,
|
||||
onSetMode = { mode -> onSetMode(item, channelId, mode) },
|
||||
mode = mode,
|
||||
// A held push switch that is already ON stays enabled, because
|
||||
// switching it off is never refused ([canSetMode]).
|
||||
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
|
||||
onSetMode = { next -> onSetMode(item, channelId, next) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.LinkedAccountRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -53,7 +53,7 @@ const val MODE_OFF = "off"
|
||||
@HiltViewModel
|
||||
class NotificationSettingsViewModel @Inject constructor(
|
||||
private val notifications: NotificationsRepository,
|
||||
private val playerShard: PlayerShardRepository,
|
||||
private val linkedAccounts: LinkedAccountRepository,
|
||||
private val pushManager: PushManager,
|
||||
sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
@@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
|
||||
data class State(
|
||||
val prefs: UiState<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,
|
||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||
val supported: Boolean = true,
|
||||
@@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
_state.update { it.copy(prefs = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||
// A linked game account gates the personal streams; failure → treat as none.
|
||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
||||
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
||||
val current = _state.value
|
||||
if (current.busy) return
|
||||
if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return
|
||||
if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
|
||||
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
@@ -160,8 +161,28 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item's controls are selectable for a user: a personal stream needs a
|
||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
||||
* Whether this item's push is held back for want of a linked game account
|
||||
* (PLAN.md §11, `docs/modules/rust/PLAN.md` D69).
|
||||
*
|
||||
* **Push only.** Until phase 11 the whole row went dead, every channel, which
|
||||
* made a personal stream impossible to switch *off* on any site where the link
|
||||
* check failed. That was every Rust site, because the check asked `module-uo`.
|
||||
* In-app and email have nothing to do with the link, and neither does turning
|
||||
* anything off. Pure so the gating is unit-tested without Compose.
|
||||
*/
|
||||
fun itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||
!item.requiresLinkedAccount || hasLinkedAccount
|
||||
fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||
item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels
|
||||
|
||||
/**
|
||||
* Whether [mode] may be set on [channel] for [item].
|
||||
*
|
||||
* The one thing refused is switching push **on** for an item [pushNeedsLink] holds
|
||||
* back. Switching anything off is never refused: a gate that can trap a switch in
|
||||
* the on position is worse than no gate.
|
||||
*/
|
||||
fun canSetMode(
|
||||
item: NotificationChannelItemDto,
|
||||
channel: String,
|
||||
mode: String,
|
||||
hasLinkedAccount: Boolean,
|
||||
): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -152,3 +175,31 @@ fun shortSteamId(steamId: String?): String {
|
||||
*/
|
||||
fun playerLabel(name: String?, steamId: String?): String =
|
||||
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
||||
|
||||
/**
|
||||
* A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else —
|
||||
* a title with a colour this build cannot read is drawn in the theme's own chip
|
||||
* colours rather than guessed at (M19).
|
||||
*/
|
||||
fun titleArgb(hex: String?): Long? {
|
||||
val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null
|
||||
return 0xFF000000L or m.groupValues[1].toLong(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a title chip of this colour needs DARK ink — the same rule as the
|
||||
* website's `contrastInk`: black or white, whichever has the higher WCAG
|
||||
* contrast. The colour is the operator's, chosen for a dark game chat, and the
|
||||
* app draws in the reader's theme, so the colour becomes the chip and the ink is
|
||||
* picked for it.
|
||||
*/
|
||||
fun titleInkIsDark(argb: Long): Boolean {
|
||||
fun linear(channel: Long): Double {
|
||||
val v = channel / 255.0
|
||||
return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
val l = 0.2126 * linear((argb shr 16) and 0xFF) +
|
||||
0.7152 * linear((argb shr 8) and 0xFF) +
|
||||
0.0722 * linear(argb and 0xFF)
|
||||
return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
@@ -25,6 +29,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -33,9 +40,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustTitleDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
@@ -50,7 +59,8 @@ import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* One Rust server: the feed, the leaderboard, who is on, and the wipes (D13).
|
||||
* One Rust server: the feed, the leaderboard, who is on, the map when the module
|
||||
* has one (D122), and the wipes (D13).
|
||||
*
|
||||
* **One screen with tabs, not four destinations** — the same call the website
|
||||
* makes, and more obviously right on a phone: the four panels are four questions
|
||||
@@ -64,6 +74,7 @@ import com.runicgateway.app.ui.components.StatusPill
|
||||
@Composable
|
||||
fun RustServerScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServerViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -87,7 +98,7 @@ fun RustServerScreen(
|
||||
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,
|
||||
ui: RustServerUi,
|
||||
viewModel: RustServerViewModel,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||
|
||||
val tabs = RustTab.entries
|
||||
val tabs = ui.tabs
|
||||
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||
tabs.forEach { tab ->
|
||||
Tab(
|
||||
@@ -142,6 +154,7 @@ private fun ServerDetail(
|
||||
viewModel::retryLeaderboard,
|
||||
)
|
||||
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||
RustTab.MAP -> RustMapPanel(serverOnline = server.online, onOpenEvent = onOpenEvent)
|
||||
RustTab.WIPES -> WipesPanel(
|
||||
ui.wipes,
|
||||
server.wipeId,
|
||||
@@ -157,6 +170,7 @@ private fun tabLabel(tab: RustTab): Int = when (tab) {
|
||||
RustTab.FEED -> R.string.rust_tab_feed
|
||||
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||
RustTab.ONLINE -> R.string.rust_tab_online
|
||||
RustTab.MAP -> R.string.rust_tab_map
|
||||
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -252,7 +275,7 @@ private fun WipeFilter(
|
||||
|
||||
@Composable
|
||||
private fun FeedPanel(
|
||||
feed: Polled<List<RustEventDto>>,
|
||||
feed: Polled<RustEventListDto>,
|
||||
filterId: String,
|
||||
onFilter: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -275,17 +298,36 @@ private fun FeedPanel(
|
||||
when (val s = feed.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
is UiState.Success -> {
|
||||
// Said once, above the rows, so a thin feed reads as withheld
|
||||
// rather than as a quiet server (org lead: nothing names who is
|
||||
// online by default).
|
||||
if (s.data.presenceHidden) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (s.data.presenceAudience == "signed_in") {
|
||||
R.string.rust_feed_presence_hidden_signin
|
||||
} else {
|
||||
R.string.rust_feed_presence_hidden_staff
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (s.data.events.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data.events, key = { it.id }) { FeedRow(it) }
|
||||
}
|
||||
items(s.data, 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) },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LeaderboardPanel(
|
||||
state: UiState<List<RustLeaderboardRowDto>>,
|
||||
@@ -417,13 +460,25 @@ private fun LeaderboardPanel(
|
||||
// Five numeric columns beside an equal-weight name column
|
||||
// left "Brannock" touching its own kill count, which the
|
||||
// walk read as one field.
|
||||
Text(
|
||||
text = playerLabel(row.name, row.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
|
||||
)
|
||||
Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) {
|
||||
Text(
|
||||
text = playerLabel(row.name, row.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// M19: the chat titles this player holds now, under the
|
||||
// name — the column is too narrow to put them beside it.
|
||||
if (row.titles.isNotEmpty()) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
row.titles.forEach { title -> TitleChip(title) }
|
||||
}
|
||||
}
|
||||
}
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
Text(
|
||||
text = column.value(row),
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun OnlinePanel(
|
||||
online: Polled<List<RustPresenceDto>>,
|
||||
online: Polled<RustOnlineDto>,
|
||||
serverOnline: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (val s = online.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
// Withheld is not empty. Below the operator's audience the server sends
|
||||
// the count and no names, and an empty list rendered as "nobody is on"
|
||||
// would be a false statement about a full server.
|
||||
is UiState.Success -> if (s.data.hidden) {
|
||||
val count = s.data.count
|
||||
EmptyView(
|
||||
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
|
||||
stringResource(
|
||||
when (s.data.audience) {
|
||||
"signed_in" -> R.string.rust_online_hidden_signin
|
||||
"public" -> R.string.rust_online_hidden_public
|
||||
else -> R.string.rust_online_hidden_staff
|
||||
},
|
||||
),
|
||||
)
|
||||
} else if (s.data.players.isEmpty()) {
|
||||
EmptyView(
|
||||
stringResource(
|
||||
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||
@@ -479,7 +576,7 @@ private fun OnlinePanel(
|
||||
if (online.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.steamId }) { player ->
|
||||
items(s.data.players, key = { it.steamId }) { player ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
|
||||
@@ -6,12 +6,15 @@ package com.runicgateway.app.ui.rust
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
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.RustWipeDto
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||
import com.runicgateway.app.data.repository.canUse
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
@@ -25,8 +28,25 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The four sections of a server's page (D13). */
|
||||
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES }
|
||||
/**
|
||||
* The sections of a server's page (D13), in the website's order. [MAP] sits
|
||||
* between Online and Wipes where the web has it (D122), and is shown only when
|
||||
* the site's module declares `map`.
|
||||
*/
|
||||
enum class RustTab {
|
||||
FEED, LEADERBOARD, ONLINE, MAP, WIPES;
|
||||
|
||||
/** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */
|
||||
val wire: String get() = name.lowercase()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The tab the website calls [wire], or null for one the app does not have.
|
||||
* The website also has `clans`, and a link to it is not a link to the feed.
|
||||
*/
|
||||
fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire }
|
||||
}
|
||||
}
|
||||
|
||||
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||
object RustSort {
|
||||
@@ -52,11 +72,18 @@ data class RustServerUi(
|
||||
val sort: String = RustSort.KILLS,
|
||||
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||
val selectedWipe: String? = null,
|
||||
val feed: Polled<List<RustEventDto>> = Polled(),
|
||||
val online: Polled<List<RustPresenceDto>> = Polled(),
|
||||
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
|
||||
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 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).
|
||||
@@ -84,16 +111,34 @@ data class RustServerUi(
|
||||
@HiltViewModel
|
||||
class RustServerViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
capabilities: SiteCapabilitiesRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||
|
||||
/** The tab a link asked for (D71), or null for the feed every page opens on. */
|
||||
private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB])
|
||||
|
||||
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
// Before the tab a link asked for, so `?tab=map` on a site without a map
|
||||
// is refused by [selectTab] and opens the feed.
|
||||
viewModelScope.launch {
|
||||
capabilities.capabilities.collect { caps ->
|
||||
val available = canUse(caps, Capability.MAP)
|
||||
_state.update {
|
||||
// The tab going away under the reader lands them on the feed,
|
||||
// which every page has and has already loaded.
|
||||
val tab = if (!available && it.tab == RustTab.MAP) RustTab.FEED else it.tab
|
||||
it.copy(mapAvailable = available, tab = tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab)
|
||||
}
|
||||
|
||||
/** A first load or a retry of the whole page. */
|
||||
@@ -118,7 +163,8 @@ class RustServerViewModel @Inject constructor(
|
||||
when (_state.value.tab) {
|
||||
RustTab.FEED -> askFeed()
|
||||
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) {
|
||||
val already = _state.value
|
||||
if (tab !in already.tabs) return
|
||||
_state.update { it.copy(tab = tab) }
|
||||
|
||||
viewModelScope.launch {
|
||||
@@ -141,6 +188,8 @@ class RustServerViewModel @Inject constructor(
|
||||
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
||||
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
||||
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
||||
// Loaded by its own view model when the tab composes.
|
||||
RustTab.MAP -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -170,6 +179,22 @@ internal fun describeWorld(server: RustServerDto): String? {
|
||||
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".
|
||||
*
|
||||
|
||||
@@ -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_section_general">General</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_unsupported">This shard hasn\'t set up push notifications yet.</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_seed">seed %1$d</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_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>
|
||||
@@ -586,6 +588,55 @@
|
||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||
<string name="rust_tab_online">Online</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_wipe_current">%1$s (this wipe)</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_leaderboard_empty">Nobody has scored here 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_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>
|
||||
@@ -605,4 +667,30 @@
|
||||
<string name="rust_col_structures">Built</string>
|
||||
<string name="rust_col_played">Played</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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
|
||||
reply(calendar)
|
||||
/** How many times the calendar was read, for the Rust map's run resolver. */
|
||||
var calendarCalls: Int = 0
|
||||
|
||||
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto {
|
||||
calendarCalls++
|
||||
return reply(calendar)
|
||||
}
|
||||
|
||||
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
|
||||
lastSlug = slug
|
||||
|
||||
@@ -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.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
@@ -31,12 +33,23 @@ class FakeRustApi : RustApi {
|
||||
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
|
||||
var wipes: RustWipeListDto = RustWipeListDto()
|
||||
var online: RustOnlineDto = RustOnlineDto()
|
||||
var map: RustMapDto = RustMapDto()
|
||||
var mapLive: RustMapLiveDto = RustMapLiveDto()
|
||||
|
||||
/**
|
||||
* Answers that replace [mapLive] one call at a time, for a test that needs an
|
||||
* answer to arrive AFTER something else happened. Consumed first to last;
|
||||
* [mapLive] answers once it is empty.
|
||||
*/
|
||||
val mapLiveQueue: ArrayDeque<suspend () -> RustMapLiveDto> = ArrayDeque()
|
||||
|
||||
var serversCalls: Int = 0
|
||||
var eventCalls: Int = 0
|
||||
var leaderboardCalls: Int = 0
|
||||
var onlineCalls: Int = 0
|
||||
var wipeCalls: Int = 0
|
||||
var mapCalls: Int = 0
|
||||
var mapLiveCalls: Int = 0
|
||||
|
||||
/** The `kind` the last feed read carried — null means it sent none at all. */
|
||||
var lastKind: String? = null
|
||||
@@ -94,4 +107,17 @@ class FakeRustApi : RustApi {
|
||||
lastId = id
|
||||
return reply(online)
|
||||
}
|
||||
|
||||
override suspend fun getMap(id: String): RustMapDto {
|
||||
mapCalls++
|
||||
lastId = id
|
||||
return reply(map)
|
||||
}
|
||||
|
||||
override suspend fun getMapLive(id: String): RustMapLiveDto {
|
||||
mapLiveCalls++
|
||||
lastId = id
|
||||
error?.let { throw it }
|
||||
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// no module installed and answered "This content couldn't be found",
|
||||
// through a green suite.
|
||||
//
|
||||
// **It asks which module, not merely whether one.** M15 put a second
|
||||
// game's self-service row under `player/`, and a row that declared the
|
||||
// wrong module's capability would render on a site running the other
|
||||
// game and answer 404 — the same failure in a new place.
|
||||
val onAModulePath = APP_MENU.filter {
|
||||
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
||||
}
|
||||
assertEquals(8, onAModulePath.size)
|
||||
assertTrue(
|
||||
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(),
|
||||
onAModulePath.all { it.capability == Capability.SHARD },
|
||||
assertEquals(9, onAModulePath.size)
|
||||
|
||||
val expected = onAModulePath.associate { entry ->
|
||||
entry.route to if (entry.route.startsWith("player/rust")) {
|
||||
Capability.RUST
|
||||
} else {
|
||||
Capability.SHARD
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
expected,
|
||||
onAModulePath.associate { it.route to it.capability },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||
import com.runicgateway.app.ui.rust.RustTab
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -102,6 +105,43 @@ class RustNavigationTest {
|
||||
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
|
||||
}
|
||||
|
||||
// ── The player's own row (M15) ────────────────────────────────────────
|
||||
|
||||
private fun player() = signedIn(Role.PLAYER)
|
||||
|
||||
private fun staff() = signedIn(Role.ADMIN)
|
||||
|
||||
private fun signedIn(role: Role) =
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||
|
||||
private fun routesFor(session: Session, capabilities: SiteCapabilities?) =
|
||||
visibleEntries(APP_MENU, session, features = null, capabilities = capabilities)
|
||||
.map { it.route }
|
||||
|
||||
@Test fun theAccountRowNeedsBothASessionAndTheModule() {
|
||||
// Signed out, the row is not there whatever the backend serves: every
|
||||
// route behind it is `requireAuth`.
|
||||
assertFalse(Routes.PLAYER_RUST in routesFor(serving(Capability.RUST)))
|
||||
|
||||
assertTrue(Routes.PLAYER_RUST in routesFor(player(), serving(Capability.RUST)))
|
||||
assertFalse(
|
||||
"a UO-only site has no Rust account row",
|
||||
Routes.PLAYER_RUST in routesFor(player(), serving(Capability.SHARD)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun staffSeeTheAccountRowToo() {
|
||||
// `PLAYER` access is `isPlayer || isStaff` — staff play the game as well,
|
||||
// and `/player/rust/*` is `requireAuth` with no role above it.
|
||||
assertTrue(Routes.PLAYER_RUST in routesFor(staff(), serving(Capability.RUST)))
|
||||
}
|
||||
|
||||
@Test fun theAccountRowHangsOnTheModuleNotOnASurfaceWord() {
|
||||
// `identity` is one of the module's surface words, like `servers` above.
|
||||
// A capability answers *is the module there*, and only `rust` does.
|
||||
assertFalse(Routes.PLAYER_RUST in routesFor(player(), serving("identity")))
|
||||
}
|
||||
|
||||
@Test fun aRustPathWithAQueryHandsOff() {
|
||||
// The website keeps tab, filter, wipe and sort in the URL; the app keeps
|
||||
// them in a view model. Resolving `?tab=wipes` natively would silently drop
|
||||
@@ -109,4 +149,38 @@ class RustNavigationTest {
|
||||
assertNull(resolveWebPath("/rust?tab=wipes"))
|
||||
assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
|
||||
}
|
||||
|
||||
// ── The notification links (phase 11, D71) ────────────────────────────
|
||||
|
||||
@Test fun theNewLeaderNoticeOpensTheLeaderboardTab() {
|
||||
// `leaderboardPath` in module-rust's triggers.js.
|
||||
assertEquals("rust/servers/main?tab=leaderboard", resolveWebPath("/rust/servers/main?tab=leaderboard"))
|
||||
assertEquals(Routes.rustServer("main", RustTab.LEADERBOARD), resolveWebPath("/rust/servers/main?tab=leaderboard"))
|
||||
assertEquals(Routes.rustServer("main", RustTab.WIPES), resolveWebPath("/rust/servers/main?tab=wipes"))
|
||||
}
|
||||
|
||||
@Test fun onlyATabTheAppHasResolves() {
|
||||
// The website has a Clans tab and the app does not: the browser, not the feed.
|
||||
assertNull(resolveWebPath("/rust/servers/main?tab=clans"))
|
||||
assertNull(resolveWebPath("/rust/servers/main?tab="))
|
||||
assertNull(resolveWebPath("/rust/servers/main?tab=wipes&wipe=w1"))
|
||||
assertNull(resolveWebPath("/rust/servers/main?sort=kills"))
|
||||
assertNull(resolveWebPath("/rust/servers/main?tab=LEADERBOARD"))
|
||||
}
|
||||
|
||||
@Test fun aTablessRouteStaysTheOneEveryOtherCallerBuilds() {
|
||||
assertEquals("rust/servers/main", Routes.rustServer("main"))
|
||||
assertEquals(RustTab.ONLINE, RustTab.fromWire("online"))
|
||||
assertNull(RustTab.fromWire("clans"))
|
||||
assertNull(RustTab.fromWire(null))
|
||||
}
|
||||
|
||||
@Test fun theAccountLinkedNoticeOpensThePlayersOwnScreen() {
|
||||
// `PATHS.account` in module-rust's triggers.js. M15 deferred this to the
|
||||
// phase that would need it.
|
||||
assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust"))
|
||||
assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust/"))
|
||||
assertNull(resolveWebPath("/player/rust?x=1"))
|
||||
assertNull(resolveWebPath("/player/shard"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import org.junit.Test
|
||||
/**
|
||||
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
||||
* §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it,
|
||||
* and the personal-item gating (a personal id needs a linked game account).
|
||||
* and the personal-item gating (a personal id needs a linked game account to
|
||||
* switch push on, and nothing else).
|
||||
*/
|
||||
class NotificationRoutingTest {
|
||||
|
||||
@@ -33,15 +34,23 @@ class NotificationRoutingTest {
|
||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||
}
|
||||
|
||||
@Test fun personalItemNeedsLinkedAccount() {
|
||||
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
|
||||
assertTrue(itemSelectable(personal, hasLinkedAccount = true))
|
||||
@Test fun personalItemNeedsALinkOnlyToSwitchPushOn() {
|
||||
val personal = NotificationChannelItemDto(
|
||||
id = "vendor.sale", personal = true, requiresLinkedAccount = true,
|
||||
channels = listOf("push", "inapp", "email"),
|
||||
)
|
||||
assertTrue(pushNeedsLink(personal, hasLinkedAccount = false))
|
||||
assertFalse(pushNeedsLink(personal, hasLinkedAccount = true))
|
||||
assertFalse(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
|
||||
assertTrue(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun generalItemIsAlwaysSelectable() {
|
||||
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||
assertTrue(itemSelectable(general, hasLinkedAccount = false))
|
||||
@Test fun generalItemIsNeverHeldBack() {
|
||||
val general = NotificationChannelItemDto(
|
||||
id = "news.post", personal = false, requiresLinkedAccount = false, channels = listOf("push"),
|
||||
)
|
||||
assertFalse(pushNeedsLink(general, hasLinkedAccount = false))
|
||||
assertTrue(canSetMode(general, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
|
||||
}
|
||||
|
||||
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
|
||||
|
||||
@@ -64,10 +64,36 @@ class NotificationSettingsViewModelTest {
|
||||
assertEquals(listOf("off", "instant"), push.modes)
|
||||
}
|
||||
|
||||
@Test fun personalItemsStillNeedALinkedAccount() {
|
||||
@Test fun aTriggerOnlyPersonalItemIsNotHeldBackAtAll() {
|
||||
// Nothing can push it, so there is no push control for the link to hold,
|
||||
// and the hint would describe a control that is not on the screen.
|
||||
val personal = prefs().items.first { it.personal }
|
||||
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false))
|
||||
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true))
|
||||
assertEquals(false, pushNeedsLink(personal, hasLinkedAccount = false))
|
||||
assertEquals(true, canSetMode(personal, "email", "instant", hasLinkedAccount = false))
|
||||
}
|
||||
|
||||
// ── Rust D69: a Rust site's raid alert ─────────────────────────────────
|
||||
|
||||
private val raid = NotificationChannelItemDto(
|
||||
id = "rust.base.destroyed", label = "Your base was raided",
|
||||
personal = true, requiresLinkedAccount = true,
|
||||
channels = listOf("push", "inapp", "email"),
|
||||
modes = mapOf("push" to "instant", "inapp" to "instant", "email" to "off"),
|
||||
)
|
||||
|
||||
@Test fun withALinkPushCanBeSwitchedOn() {
|
||||
assertEquals(true, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun withoutALinkEveryChannelCanStillBeSwitchedOff() {
|
||||
// Phase 10's criterion ends "and can be switched off there". Before phase
|
||||
// 11 the whole row was disabled whenever the link check failed, and it
|
||||
// always failed on a Rust site.
|
||||
listOf(CHANNEL_PUSH, "inapp", "email").forEach { channel ->
|
||||
assertEquals(channel, true, canSetMode(raid, channel, MODE_OFF, hasLinkedAccount = false))
|
||||
}
|
||||
assertEquals(true, canSetMode(raid, "inapp", "instant", hasLinkedAccount = false))
|
||||
assertEquals(false, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
|
||||
}
|
||||
|
||||
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
|
||||
|
||||
@@ -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
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -102,4 +103,47 @@ class RustDtoTest {
|
||||
assertNull(event.str("name"))
|
||||
assertEquals(FeedTone.SERVER, describe(event).tone)
|
||||
}
|
||||
|
||||
// ── The next wipe (module-rust phase 16, M18) ──────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the next wipe decodes, and is null against a module that does not send it`() {
|
||||
val list = json.decodeFromString<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
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -131,4 +132,41 @@ class RustFormatTest {
|
||||
assertEquals("1234", shortSteamId("1234"))
|
||||
assertEquals("", shortSteamId(null))
|
||||
}
|
||||
|
||||
// ── The next wipe (module-rust phase 16, M18) ──────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the next wipe is said in the phone's zone with how far away it is`() {
|
||||
val sept25 = Instant.parse("2026-09-25T12:00:00Z")
|
||||
// The forced wipe: 18:00 UTC is 19:00 in London in summer, 13:00 in Chicago.
|
||||
assertEquals(
|
||||
"Thu 1 Oct, 19:00 · in 6 days",
|
||||
nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("Europe/London"), uk),
|
||||
)
|
||||
assertTrue(
|
||||
nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("America/Chicago"), Locale.US)!!
|
||||
.startsWith("Thu 1 Oct, 1:00"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no instant is no line`() {
|
||||
assertNull(nextWipeWhen(null, now, utc, uk))
|
||||
assertNull(nextWipeWhen("soon", now, utc, uk))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title colour is read only as #rrggbb, and its ink is whichever reads`() {
|
||||
assertEquals(0xFFFFAA55L, titleArgb("#ffaa55"))
|
||||
assertEquals(0xFFFFAA55L, titleArgb("#FFAA55"))
|
||||
assertNull(titleArgb("red"))
|
||||
assertNull(titleArgb(null))
|
||||
|
||||
// The same four the website's contrastInk test asserts, so the two
|
||||
// clients draw a title the same way.
|
||||
assertTrue(titleInkIsDark(titleArgb("#ffff00")!!))
|
||||
assertTrue(titleInkIsDark(titleArgb("#ffaa55")!!))
|
||||
assertTrue(titleInkIsDark(titleArgb("#ff0000")!!))
|
||||
assertFalse(titleInkIsDark(titleArgb("#1a1a8c")!!))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.InstalledModuleDto
|
||||
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
@@ -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.RustWipeDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.VersionDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.api.fake.FakeRustApi
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -35,9 +43,13 @@ class RustServerViewModelTest {
|
||||
private val api = FakeRustApi()
|
||||
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,
|
||||
SavedStateHandle(mapOf("serverId" to id)),
|
||||
capabilities,
|
||||
SavedStateHandle(mapOf("serverId" to id, "tab" to tab)),
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -100,6 +112,35 @@ class RustServerViewModelTest {
|
||||
assertEquals(onlineBefore + 1, api.onlineCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withheld names arrive as withheld, with the count, never as an empty list`() {
|
||||
// Nothing names who is online by default (org lead, 2026-09-22). The
|
||||
// screen's whole job with this answer is to say "12 online" rather than
|
||||
// "nobody is on", so the state must carry `hidden` and `count` intact.
|
||||
api.online = RustOnlineDto(players = emptyList(), hidden = true, count = 12, audience = "staff")
|
||||
val vm = viewModel()
|
||||
|
||||
vm.selectTab(RustTab.ONLINE)
|
||||
|
||||
val state = vm.state.value.online.state
|
||||
assertTrue(state is UiState.Success)
|
||||
val answer = (state as UiState.Success).data
|
||||
assertTrue(answer.hidden)
|
||||
assertEquals(12, answer.count)
|
||||
assertEquals("staff", answer.audience)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a feed with its players withheld says so in the state`() {
|
||||
api.events = RustEventListDto(events = emptyList(), presenceHidden = true, presenceAudience = "signed_in")
|
||||
val vm = viewModel()
|
||||
|
||||
val state = vm.state.value.feed.state
|
||||
assertTrue(state is UiState.Success)
|
||||
assertTrue((state as UiState.Success).data.presenceHidden)
|
||||
assertEquals("signed_in", state.data.presenceAudience)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a poll on a still panel asks for nothing but the server line`() {
|
||||
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))
|
||||
@@ -190,4 +231,77 @@ class RustServerViewModelTest {
|
||||
|
||||
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