16 Commits

Author SHA1 Message Date
63bdf824be Merge pull request 'feat(rust): chat titles on the leaderboard (module-rust phase 17, M19)' (#53) from feat/rust-phase-17-titles into edge
Reviewed-on: #53
2026-09-25 23:31:55 +00:00
96aae7239b feat(rust): chat titles on the leaderboard (module-rust phase 17, M19)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m38s
Each leaderboard row carries `titles: [{ text, color }]` from a phase-17
module: the chat titles the player holds now. Drawn as chips under the
name, in the operator's colour with black or white ink chosen by WCAG
contrast (the website's rule). Absent from an older module, so the field
defaults to empty and nothing is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:22 -05:00
341c13a98c Merge pull request 'feat(rust): a Rust server's next wipe (module-rust phase 16, M18)' (#52) from feat/rust-phase-16-next-wipe into edge
Reviewed-on: #52
2026-09-25 19:18:14 +00:00
89c317f694 feat(rust): a Rust server's next wipe (module-rust phase 16, M18)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m28s
RustServerDto gains nextWipe { at, source }, null both when the operator
set no schedule and against a module older than phase 16. The server
list row and the server screen's header read "Next wipe Thu 1 Oct,
19:00 · in 6 days" in the phone's own zone, and "(rescheduled)" for a
one-off date the operator set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 13:30:12 -05:00
edcbc0727b Merge pull request 'feat(rust): the map on a phone (module-rust phase 15, M17)' (#51) from feat/rust-phase-15-map into edge
Reviewed-on: #51
2026-09-25 17:43:20 +00:00
09eb101c52 feat(rust): the map on a phone (module-rust phase 15, M17)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m53s
A fifth tab on the Rust server screen, between Online and Wipes, shown
when the site's module declares `map` under the app's one capability
rule (D122). `?tab=map` opens it; on a site without a map it opens the
feed.

The phone draws the map itself (D121): Coil loads the picture once per
map as RGB_565, and a Compose Canvas draws the grid and the markers,
with pinch, pan and double tap. The frame is §30.3's transform, ported
from the web's mapGeometry.js with its test cases. Markers keep their
size on screen; a zone is drawn on the ground. Nothing here gates a
layer: map/live is projected per viewer on the server, and the app
draws what it is sent.

- The legend: a switch per layer the viewer was sent, and who can see
  each one they were not (with the D113 line). The mates row offers
  the Steam link to a signed-in, unlinked viewer.
- The tap card: what a marker is, its grid square, and what its layer
  carried. A site-event marker whose run core's public calendar lists
  (D125) has Open event, which opens the app's own event page on that
  run (D123). A rehearsal's or unlisted event's marker says only
  "Site event".
- The poll: map/live every pollMs (10 s) while RESUMED on the Map tab
  (D124), and not at all for a viewer sent nothing that moves. A new
  mapKey re-reads the map, so a new seed's picture follows in place.
- Per account: the map's state is dropped and re-asked on a change of
  signed-in account, and an answer asked for the previous account is
  discarded when it lands.

Found on the emulator walk and fixed here: the reader's zoom was lost
on the way back from the event page, and again whenever the status line
under the map changed length (a resize was treated as a rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 08:28:26 -05:00
adf9547e01 Merge pull request 'feat(notifications): a Rust notification on a phone (module-rust phase 11)' (#50) from feat/rust-phase-11-notifications into edge
Reviewed-on: #50
2026-09-23 20:49:28 +00:00
640b423dfa feat(notifications): a Rust notification on a phone (module-rust phase 11)
All checks were successful
PR Checks / android-build (pull_request) Successful in 8m54s
Three places the app still assumed its site was a UO shard (D69-D71):

- The linked-account check behind personal streams asked module-uo's
  /player/shard/accounts. On a Rust site that failed, and the whole raid
  alert row was disabled in every channel. It now asks the site's own
  module by capability, and the link only holds back switching push ON;
  switching anything off is never refused.
- A tickle whose ref names an inbox row is titled from that row, pulled
  over the authenticated inbox API (wake-and-pull). The lock screen shows
  only the per-stream title. Each tickle runs in its own job, because
  collectLatest would cancel a pull on the next tickle.
- /player/rust and /rust/servers/<id>?tab=<tab> open natively; a tab the
  app does not have (clans) still hands off to the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 15:26:32 -05:00
8e3c62d966 Merge pull request 'fix(rust): say who is online is withheld, never that nobody is' (#49) from fix/rust-presence-visibility into edge
Reviewed-on: #49
2026-09-23 05:36:33 +00:00
f695eb6c45 fix(rust): say who is online is withheld, never that nobody is
Some checks failed
PR Checks / android-build (pull_request) Failing after 17m40s
Module-Rust now withholds who is online below an operator-chosen
audience - staff by default (org lead, 2026-09-22: nothing names who is
online by default). The Online route answers an empty list with hidden,
count and audience; the feed answers presenceHidden and
presenceAudience.

Before this the app would have rendered the withheld list as "the
island is empty" on a full server. Now:

* RustOnlineDto and RustEventListDto carry the flags; the repository
  and view model keep the whole answer instead of its rows.
* the Online tab says "2 players online" and who can see the names;
  the feed says, once above the rows, that joins, deaths and chat are
  not shown.
* an older module without the flags decodes as visible, as before.

Walked on an emulator against a core with the module installed: the
withheld panel and the feed note as a signed-in player at the staff
default, and the names appearing on the next poll after the fleet was
widened to signed-in - which also proves the app's bearer session
reaches the module's viewer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 00:30:31 -05:00
59d955a11d Merge pull request 'feat(rust): a player's own Rust account on the phone — M15 (module-rust phase 8, Android leg B)' (#48) from feat/rust-p8-android-b into edge
Reviewed-on: #48
2026-09-23 01:52:10 +00:00
a079bd481e fix(rust): say when and where an account was linked, as the website's row does
Some checks failed
PR Checks / android-build (pull_request) Failing after 22m6s
Found by the emulator walk, not by the suite: the app's row carried the
name and the Steam id and stopped there, while the website's says "linked
just now on rust-oxide". Neither fact is part of the identity — a link is
fleet-wide — but which server minted the code is where a support
conversation starts, and the module's own schema comment says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:32:26 -05:00
4878b74e09 feat(rust): a player's own Rust account on the phone — M15 (module-rust phase 8, Android leg B)
R10's leg B: identity, and the half of R2 a player is allowed to see. Every
route this calls existed and answered before a line of Kotlin was written,
except the entitlement read, which is this phase's one website change.

**One drawer row under the player group, shaped like `CharactersScreen` one
game along**: the code card first, then what the code got them. A link is
fleet-wide, so it is not a tab under one server — a Steam account is one
person wherever they play, while stats are per server and per wipe.

**Gated on `rust`, not on `identity`.** The module declares a surface word
per feature, and D16's rule is that a capability answers one question — *is
the module there*. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is
right here: `/player/rust/*` is `requireAuth` with no role above it, and
staff play the game too.

**Two reads, neither blocking the other.** 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 both halves at once, and a failure on either leaves the
other standing.

**The four refusals stay four pieces of advice.** 400 is a spent code, 409
is a Steam account another account holds (`/unlink` in game releases it),
429 is the limiter, and 503 is a server that could not be reached — where
the code is still good, so it may not say "get a new one". A player told
otherwise goes back to the same unreachable server for another code.

The app does no scope arithmetic: `*` never reaches a screen. Each entry
arrives with its servers already resolved and each marked, because a second
implementation of `inScope` is a second thing to keep true.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:50:04 -05:00
52 changed files with 4741 additions and 109 deletions

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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".
*

View File

@@ -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 &amp; 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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