From 09eb101c52368cfbe5d23bdcdb2241b50d721ce2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 08:28:26 -0500 Subject: [PATCH] feat(rust): the map on a phone (module-rust phase 15, M17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../com/runicgateway/app/data/api/RustApi.kt | 17 + .../app/data/api/dto/EventsDto.kt | 5 + .../app/data/api/dto/RustMapDto.kt | 174 +++++ .../app/data/repository/RustRepository.kt | 10 + .../repository/SiteCapabilitiesRepository.kt | 12 + .../java/com/runicgateway/app/ui/RunicApp.kt | 6 +- .../app/ui/rust/EventRunResolver.kt | 69 ++ .../com/runicgateway/app/ui/rust/MapFrame.kt | 243 +++++++ .../runicgateway/app/ui/rust/MapMarkers.kt | 154 ++++ .../runicgateway/app/ui/rust/RustMapPanel.kt | 680 ++++++++++++++++++ .../app/ui/rust/RustMapViewModel.kt | 187 +++++ .../app/ui/rust/RustServerScreen.kt | 11 +- .../app/ui/rust/RustServerViewModel.kt | 38 +- app/src/main/res/values/strings.xml | 49 ++ .../app/data/api/fake/FakeEventsApi.kt | 9 +- .../app/data/api/fake/FakeRustApi.kt | 26 + .../app/ui/rust/EventRunResolverTest.kt | 94 +++ .../runicgateway/app/ui/rust/MapFrameTest.kt | 213 ++++++ .../app/ui/rust/MapMarkersTest.kt | 141 ++++ .../app/ui/rust/RustMapDtoTest.kt | 106 +++ .../app/ui/rust/RustMapViewModelTest.kt | 258 +++++++ .../app/ui/rust/RustServerViewModelTest.kt | 88 ++- 22 files changed, 2578 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt index cdd9c92..987c566 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt @@ -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 } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt index 25e5d6b..a89f932 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt @@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable * * [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never * the reader's. See [com.runicgateway.app.ui.events.eventTime]. + * + * [runId] is on a run and never on a projection, which has nothing committed to + * it. Core added it for Rust D125, so a map marker naming a run can find its + * event; a core older than that omits it, and the marker stays unlinked. */ @Serializable data class EventCalendarEntryDto( val kind: String = "run", + val runId: Long? = null, val title: String = "", val slug: String = "", val seriesName: String? = null, diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt new file mode 100644 index 0000000..dc68bf8 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt @@ -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? = null, + val layers: RustMapLayersDto = RustMapLayersDto(), + val mates: RustMapMatesDto = RustMapMatesDto(), + val pollMs: Long? = null, +) + +/** + * Where the picture is. + * + * [path] is relative to `/api/v1` and carries the picture's hash, so it is + * immutable: a hash that is no longer current is a 404, never the new bytes. + */ +@Serializable +data class RustMapPictureDto( + val path: String = "", + val source: String? = null, + val fetchedAt: String? = null, +) + +/** + * How a world position reaches a pixel (§30.3). [oceanMargin] is in pixels and is + * not scaled; [gridCells] and [gridCellSize] are the game's own grid (D119). + */ +@Serializable +data class RustMapGeometryDto( + val worldSize: Double = 0.0, + val oceanMargin: Double = 0.0, + val width: Double = 0.0, + val height: Double = 0.0, + val gridCells: Int = 0, + val gridCellSize: Double = 0.0, + val background: String? = null, +) + +@Serializable +data class RustMonumentDto( + val value: String = "", + val kind: String = "", + val label: String = "", + val grid: String? = null, + val x: Double = 0.0, + val z: Double = 0.0, +) + +@Serializable +data class RustMapLayersDto( + val world: RustMapLayerDto = RustMapLayerDto(), + val events: RustMapLayerDto = RustMapLayerDto(), + val players: RustMapLayerDto = RustMapLayerDto(), + val bases: RustMapLayerDto = RustMapLayerDto(), +) + +/** + * Whether this viewer gets one layer, and who does. A hidden layer never says + * what it holds. [cappedByPresence] is the players layer's alone: it is narrower + * than its own switch because who may see who is online is narrower (D113). + */ +@Serializable +data class RustMapLayerDto( + val visible: Boolean = false, + val audience: String? = null, + val cappedByPresence: Boolean = false, +) + +/** Whether this viewer gets their own position and their online clan mates' (D115). */ +@Serializable +data class RustMapMatesDto( + val visible: Boolean = false, + /** The server's switch. */ + val on: Boolean = false, + val linked: Boolean = false, + val signedIn: Boolean = false, +) + +/** + * `GET /public/rust/servers/{id}/map/live`: what moves, cut down to this viewer. + * + * [live] false means the game did not answer and [reason] says why; the picture + * stays up. Positions are never stored by the site, so there is no last-known + * answer to fall back on here the way the presence board has one. + */ +@Serializable +data class RustMapLiveDto( + val live: Boolean = false, + val reason: String? = null, + val mapKey: String? = null, + val world: List? = null, + val events: List? = null, + val players: List? = null, + val playersTruncated: Boolean = false, + val bases: List? = null, + val basesTruncated: Boolean = false, + val mates: List? = null, +) + +/** A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply` 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt index a051bb8..7bd5779 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt @@ -9,6 +9,8 @@ import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.data.api.RustApi import com.runicgateway.app.data.api.dto.RustEventListDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto import com.runicgateway.app.data.api.dto.RustOnlineDto import com.runicgateway.app.data.api.dto.RustServerDto import com.runicgateway.app.data.api.dto.RustWipeDto @@ -86,4 +88,12 @@ class RustRepository @Inject constructor( */ suspend fun online(id: String): ApiResult = safeApiCall { api.getOnline(id) } + + /** The map's picture, frame and layer gates for this viewer. */ + suspend fun map(id: String): ApiResult = + safeApiCall { api.getMap(id) } + + /** What moves on the map, as this viewer may see it. */ + suspend fun mapLive(id: String): ApiResult = + safeApiCall { api.getMapLive(id) } } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt index 1cd3c48..7963df5 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt @@ -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" } diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 78d39cd..b517a47 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -644,7 +644,11 @@ private fun RunicNavHost( }, ), ) { - 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)) }) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt b/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt new file mode 100644 index 0000000..8ecea5e --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt @@ -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 = 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): Map = mutex.withLock { + val unknown = runIds.any { it !in slugs } + val last = readAt + if (unknown && (last == null || now() - last >= refreshMs)) read() + runIds.mapNotNull { id -> slugs[id]?.let { id to it } }.toMap() + } + + private suspend fun read() { + readAt = now() + val result = events.calendar() + if (result is ApiResult.Ok) { + // Core sends the id as a number and the plugin as a string, so they + // meet as text. + 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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt b/app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt new file mode 100644 index 0000000..224acd6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt @@ -0,0 +1,243 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121). + * + * Ported from the web's `mapGeometry.js`, with its test cases, so the phone and + * the page cannot place the same crate in two squares. Rust's world is centred on + * the origin, x east and z north. The picture is the world at a scale, with an + * ocean margin around it that is measured in **pixels** and is not scaled (the + * rig's 3 000 m map is 3 000 × 0.5 + 2 × 500 = 2 500 px). So: + * + * s = (width − 2 × margin) / worldSize + * px = (x + worldSize / 2) × s + margin + * py = (z + worldSize / 2) × s + margin measured UP from the bottom edge + * + * The web measures `py` up because Leaflet's simple frame grows north. A canvas + * grows **down**, so [toPixel] answers `height − py`, and that flip happens here + * and nowhere else. + * + * The grid is the **game's** (D119): [RustMapGeometryDto.gridCells] cells of + * [RustMapGeometryDto.gridCellSize] metres per side, lettered from the west and + * numbered from the north, `A0` at the north-west corner. Nothing here assumes a + * cell size, and nothing here assumes the rig's world size either. + */ +class MapFrame(val geometry: RustMapGeometryDto) { + + /** Picture pixels per world metre, or 0 for a geometry that cannot place anything. */ + val scale: Double = if (geometry.worldSize > 0 && geometry.width > 0) { + (geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize + } else { + 0.0 + } + + /** True when positions can be placed at all. */ + val canPlace: Boolean get() = scale > 0 + + val width: Double get() = geometry.width + val height: Double get() = geometry.height + + private val half: Double get() = geometry.worldSize / 2 + + /** A world position as a point in the picture's pixels, y measured down from the top. */ + fun toPixel(x: Double, z: Double): PicturePoint = PicturePoint( + x = (x + half) * scale + geometry.oceanMargin, + y = geometry.height - ((z + half) * scale + geometry.oceanMargin), + ) + + /** A distance on the ground as picture pixels: a zone's radius. */ + fun metres(m: Double): Double = m * scale + + /** The grid label for a world position, the way the in-game map writes it; null without a grid. */ + fun gridLabel(x: Double, z: Double): String? { + val g = geometry + if (g.gridCells <= 0 || g.gridCellSize <= 0) return null + val col = floor((x + half) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1) + val row = floor((half - z) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1) + return "${column(col)}$row" + } + + /** + * The grid in world metres: [MapGrid.lines] as pairs of ends, and a label at + * each cell's north-west corner. + */ + fun grid(): MapGrid { + val g = geometry + if (g.gridCells <= 0 || g.gridCellSize <= 0) return MapGrid(emptyList(), emptyList()) + val n = g.gridCells + val c = g.gridCellSize + val lines = buildList { + for (i in 0..n) { + val at = -half + i * c + add(WorldLine(at, half, at, half - n * c)) + add(WorldLine(-half, half - i * c, -half + n * c, half - i * c)) + } + } + val labels = buildList { + for (col in 0 until n) { + for (row in 0 until n) { + add(GridLabel("${column(col)}$row", -half + col * c, half - row * c)) + } + } + } + return MapGrid(lines, labels) + } + + companion object { + /** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */ + fun column(index: Int): String { + val name = StringBuilder() + var n = index + 1 + while (n > 0) { + val r = (n - 1) % 26 + name.insert(0, ('A' + r)) + n = (n - 1) / 26 + } + return name.toString() + } + } +} + +/** A point in the picture's own pixels, y down. */ +data class PicturePoint(val x: Double, val y: Double) + +/** A line in world metres, from (x1, z1) to (x2, z2). */ +data class WorldLine(val x1: Double, val z1: Double, val x2: Double, val z2: Double) + +/** A grid label and the world position of its cell's north-west corner. */ +data class GridLabel(val text: String, val x: Double, val z: Double) + +data class MapGrid(val lines: List, val labels: List) + +/** + * The picture's address, relative to the site's base. + * + * The module hands out `path` relative to `/api/v1`, as it does every path. It + * is joined here as **relative** (`api/v1/…`, no leading slash), the way every + * Retrofit path in this app is written, so a site served under a prefix keeps + * the prefix when [com.runicgateway.app.ui.LocalAssetResolver] resolves it. + */ +fun mapPictureUrl(path: String): String = "api/v1/" + path.trimStart('/') + +/** Seconds as `m:ss`, for a locked crate's hack. */ +fun countdown(seconds: Number?): String { + val s = max(0, (seconds?.toDouble() ?: 0.0).roundToInt()) + return "${s / 60}:${(s % 60).toString().padStart(2, '0')}" +} + +/** + * Where the picture sits on screen: screen pixels per picture pixel, and the + * screen position of the picture's top-left corner. + * + * Pure, so the gesture arithmetic is tested without Compose. **Zoom runs from + * fit-to-screen to [MAX_ZOOM] screen pixels per picture pixel** (§31.2), and a + * pan may take the view a quarter of the picture past its edges: some things sail + * off the edge of the world, and the rig's cargo ship spent phase 14's probe + * outside the picture entirely. + */ +data class MapTransform(val scale: Float, val offsetX: Float, val offsetY: Float) { + + fun screenX(pictureX: Double): Float = (pictureX * scale).toFloat() + offsetX + fun screenY(pictureY: Double): Float = (pictureY * scale).toFloat() + offsetY + + /** The picture pixel under a screen point. */ + fun pictureX(screenX: Float): Double = ((screenX - offsetX) / scale).toDouble() + fun pictureY(screenY: Float): Double = ((screenY - offsetY) / scale).toDouble() + + /** + * One step of a pinch: zoom by [zoom] about [centroidX], [centroidY] and move + * by [panX], [panY], then clamp to what the view allows. + */ + fun transformed( + view: ViewSize, + picture: ViewSize, + centroidX: Float, + centroidY: Float, + panX: Float, + panY: Float, + zoom: Float, + ): MapTransform { + val next = (scale * zoom).coerceIn(minScale(view, picture), maxScale(view, picture)) + val factor = next / scale + return MapTransform( + scale = next, + offsetX = centroidX - (centroidX - offsetX) * factor + panX, + offsetY = centroidY - (centroidY - offsetY) * factor + panY, + ).clamped(view, picture) + } + + /** + * The same view in a box of a different size: the picture pixel that was at + * the centre stays at the centre, and the zoom is clamped to what the new box + * allows. + * + * **Not a refit.** The walk found the reader's zoom thrown away when the + * status line under the map went from two lines to one: the map's box grew by + * a line's height, and a rule that refitted on every new size could not tell + * that from a rotation. A rotation keeps its centre too, which is what a + * reader turning the phone to see more of the same place wants. + */ + fun resized(from: ViewSize, to: ViewSize, picture: ViewSize): MapTransform { + val centreX = pictureX(from.width / 2) + val centreY = pictureY(from.height / 2) + val s = scale.coerceIn(minScale(to, picture), maxScale(to, picture)) + return MapTransform( + scale = s, + offsetX = to.width / 2 - (centreX * s).toFloat(), + offsetY = to.height / 2 - (centreY * s).toFloat(), + ).clamped(to, picture) + } + + /** + * Keep the view's centre within the picture plus a quarter of it on every + * side, so a reader can follow something off the edge and cannot lose the map. + */ + fun clamped(view: ViewSize, picture: ViewSize): MapTransform { + val cx = view.width / 2 + val cy = view.height / 2 + val w = picture.width * scale + val h = picture.height * scale + return copy( + offsetX = offsetX.coerceIn(cx - w * (1 + OVERSCROLL), cx + w * OVERSCROLL), + offsetY = offsetY.coerceIn(cy - h * (1 + OVERSCROLL), cy + h * OVERSCROLL), + ) + } + + companion object { + /** Screen pixels per picture pixel at the closest zoom. */ + const val MAX_ZOOM = 4f + + /** How far past its edges the view may be taken, as a share of the picture. */ + const val OVERSCROLL = 0.25f + + /** The whole picture, centred. */ + fun fit(view: ViewSize, picture: ViewSize): MapTransform { + val s = minScale(view, picture) + return MapTransform( + scale = s, + offsetX = (view.width - picture.width * s) / 2, + offsetY = (view.height - picture.height * s) / 2, + ) + } + + fun minScale(view: ViewSize, picture: ViewSize): Float { + if (picture.width <= 0 || picture.height <= 0) return 1f + return min(view.width / picture.width, view.height / picture.height) + } + + /** Never less than fit: a tiny picture on a large screen still fits. */ + fun maxScale(view: ViewSize, picture: ViewSize): Float = max(MAX_ZOOM, minScale(view, picture)) + } +} + +/** A width and height in pixels. */ +data class ViewSize(val width: Float, val height: Float) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt b/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt new file mode 100644 index 0000000..e2de901 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt @@ -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): List = buildList { + if (MapLayer.WORLD in shown) { + map.monuments.orEmpty().forEach { + add(MapMarker(MapLayer.WORLD, "monument", it.x, it.z, name = it.label, grid = it.grid)) + } + live?.world.orEmpty().forEach { + add(MapMarker(MapLayer.WORLD, it.kind, it.x, it.z, 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, + frame: MapFrame, + transform: MapTransform, + tapX: Float, + tapY: Float, + reachPx: Float, +): MapMarker? { + fun distance(m: MapMarker): Double { + val p = frame.toPixel(m.x, m.z) + val d = hypot(transform.screenX(p.x) - tapX.toDouble(), transform.screenY(p.y) - tapY.toDouble()) + if (!m.isZone) return d + val radius = frame.metres(m.radiusMetres ?: 0.0) * transform.scale + return max(0.0, d - radius) + } + + fun closest(candidates: List): MapMarker? = candidates + .asReversed() + .map { it to distance(it) } + .filter { it.second <= reachPx } + .minByOrNull { it.second } + ?.first + + return closest(markers.filterNot { it.isZone }) ?: closest(markers.filter { it.isZone }) +} + +/** The distinct run ids the events layer names, for [EventRunResolver]. */ +fun eventRunIds(live: RustMapLiveDto?): Set = + live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet() diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt new file mode 100644 index 0000000..e4fbb12 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt @@ -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, + selected: MapMarker?, + resetKey: String?, + onSelect: (MapMarker?) -> Unit, +) { + val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat()) + var view by remember { mutableStateOf(null) } + // Null until the reader moves the map: until then, and after a new map + // resets it, the map is drawn fitted to the view. **Saveable**, because + // Open event leaves this screen and Back returns to it: the walk found the + // reader's zoom gone on the way back, the card still up over a whole-world + // view of the marker it described. + var moved by rememberSaveable(resetKey, pictureSize, stateSaver = TransformSaver) { + mutableStateOf(null) + } + val transform = moved ?: view?.let { MapTransform.fit(it, pictureSize) } + + // Read through updated state so the gesture handlers, which are installed + // once, act on what is drawn now rather than on what was drawn when they were. + val currentMarkers by rememberUpdatedState(markers) + val currentTransform by rememberUpdatedState(transform) + val currentFrame by rememberUpdatedState(frame) + + val textMeasurer = rememberTextMeasurer() + val labelStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = GRID_LABEL) + val grid = remember(frame) { frame.grid() } + val labels: List> = remember(grid, textMeasurer) { + grid.labels.map { it to textMeasurer.measure(it.text, labelStyle) } + } + val description = stringResource(R.string.rust_map_description) + + Canvas( + Modifier + .fillMaxSize() + .clipToBounds() + .background(SURROUND) + .semantics { contentDescription = description } + .onSizeChanged { + val next = ViewSize(it.width.toFloat(), it.height.toFloat()) + // A new size keeps the reader's view rather than refitting: the + // box grows and shrinks with the status line under it, and a + // rotation is still the same place. The first size after a + // return is not a new one, and keeps the restored zoom. + val previous = view + if (next != previous) { + val m = moved + if (previous != null && m != null) moved = m.resized(previous, next, pictureSize) + view = next + } + } + .pointerInput(pictureSize) { + detectTransformGestures { centroid, pan, zoom, _ -> + val v = view ?: return@detectTransformGestures + val t = currentTransform ?: return@detectTransformGestures + moved = t.transformed(v, pictureSize, centroid.x, centroid.y, pan.x, pan.y, zoom) + } + } + .pointerInput(pictureSize) { + detectTapGestures( + onTap = { at -> + val t = currentTransform ?: return@detectTapGestures + onSelect(nearestMarker(currentMarkers, currentFrame, t, at.x, at.y, TAP_REACH.toPx())) + }, + onDoubleTap = { at -> + val v = view ?: return@detectTapGestures + val t = currentTransform ?: return@detectTapGestures + moved = t.transformed(v, pictureSize, at.x, at.y, 0f, 0f, 2f) + }, + ) + }, + ) { + val t = transform ?: return@Canvas + val left = t.offsetX + val top = t.offsetY + val w = pictureSize.width * t.scale + val h = pictureSize.height * t.scale + + // Without a picture the geometry is filled with the game's own colour. + drawRect(background, topLeft = Offset(left, top), size = androidx.compose.ui.geometry.Size(w, h)) + picture?.let { + drawImage( + image = it, + dstOffset = IntOffset(left.roundToInt(), top.roundToInt()), + dstSize = IntSize(w.roundToInt(), h.roundToInt()), + filterQuality = FilterQuality.Low, + ) + } + + if (showGrid) drawGrid(frame, t, grid, labels) + markers.forEach { drawMarker(frame, t, it, highlighted = it == selected) } + } +} + +private fun DrawScope.drawGrid( + frame: MapFrame, + t: MapTransform, + grid: MapGrid, + labels: List>, +) { + val stroke = 1.dp.toPx() + fun at(x: Double, z: Double): Offset { + val p = frame.toPixel(x, z) + return Offset(t.screenX(p.x), t.screenY(p.y)) + } + grid.lines.forEach { drawLine(GRID_LINE, at(it.x1, it.z1), at(it.x2, it.z2), strokeWidth = stroke) } + + // Labels only once a cell is wide enough on screen to hold one: at the + // fitted zoom a 20-cell map's labels overlap into a wall of text (found on + // the phase 14 walk, and the web's rule). + val cellPx = frame.metres(frame.geometry.gridCellSize) * t.scale + if (cellPx < LABEL_MIN_CELL.toPx()) return + val pad = Offset(3.dp.toPx(), 2.dp.toPx()) + labels.forEach { (label, layout) -> + val corner = at(label.x, label.z) + // Only the ones on screen: a zoomed-in 20-cell map has 400 and shows a few. + if (corner.x > size.width || corner.y > size.height) return@forEach + if (corner.x + cellPx < 0 || corner.y + cellPx < 0) return@forEach + drawText(layout, topLeft = corner + pad) + } +} + +/** + * One marker. **Points are drawn at a fixed size on screen** whatever the zoom, so + * a dot does not grow over the monument it sits on at 4×; **a zone is drawn on + * the ground**, in metres, because how far it reaches is what it says (§31.4). + */ +private fun DrawScope.drawMarker(frame: MapFrame, t: MapTransform, m: MapMarker, highlighted: Boolean) { + val p = frame.toPixel(m.x, m.z) + val centre = Offset(t.screenX(p.x), t.screenY(p.y)) + + if (m.isZone) { + val radius = (frame.metres(m.radiusMetres ?: 0.0) * t.scale).toFloat() + drawCircle(EVENT.copy(alpha = 0.12f), radius, centre) + drawCircle(EVENT, radius, centre, style = Stroke(width = (if (highlighted) 3 else 2).dp.toPx())) + return + } + + val style = markerStyle(m) + val radius = style.radius.dp.toPx() + drawCircle(style.colour.copy(alpha = 0.95f), radius, centre) + drawCircle(Color.Black, radius, centre, style = Stroke(width = style.outline.dp.toPx())) + if (highlighted) drawCircle(Color.White, radius + 4.dp.toPx(), centre, style = Stroke(width = 2.dp.toPx())) +} + +/** The zoom and pan as three floats, so they survive leaving the screen. */ +private val TransformSaver = Saver( + save = { t -> t?.let { floatArrayOf(it.scale, it.offsetX, it.offsetY) } }, + restore = { MapTransform(it[0], it[1], it[2]) }, +) + +private data class MarkerStyle(val colour: Color, val radius: Int, val outline: Int = 1) + +/** The web's colours and sizes (`MapView.jsx`), so the two maps read alike. */ +private fun markerStyle(m: MapMarker): MarkerStyle = when (m.layer) { + MapLayer.WORLD -> when (m.kind) { + "monument" -> MarkerStyle(MONUMENT, 4) + "cargo" -> MarkerStyle(WORLD_COLOURS.getValue("cargo"), 7) + 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, onToggle: (MapLayer) -> Unit) { + LegendRow(stringResource(R.string.rust_map_layer_grid), emptyList(), true, MapLayer.GRID in shown, null) { + onToggle(MapLayer.GRID) + } + LAYER_ROWS.forEach { (layer, labelRes) -> + val gate = map.layers.of(layer) + var note = if (gate.visible) null else hiddenNote(gate) + if (layer == MapLayer.PLAYERS && gate.cappedByPresence && !gate.visible) { + note = stringResource(R.string.rust_map_capped, note.orEmpty()) + } + if (gate.visible && layer == MapLayer.PLAYERS && live?.playersTruncated == true) { + note = stringResource(R.string.rust_map_players_truncated) + } + if (gate.visible && layer == MapLayer.BASES && live?.basesTruncated == true) { + note = stringResource(R.string.rust_map_bases_truncated) + } + LegendRow(stringResource(labelRes), SWATCHES.getValue(layer), gate.visible, gate.visible && layer in shown, note) { + onToggle(layer) + } + } + val mates = map.mates + if (mates.visible) { + LegendRow( + stringResource(R.string.rust_map_layer_mates), + SWATCHES.getValue(MapLayer.MATES), + true, + MapLayer.MATES in shown, + stringResource(R.string.rust_map_mates_note), + ) { onToggle(MapLayer.MATES) } + } else if (mates.on && mates.signedIn && !mates.linked) { + // Offered only to a signed-in, unlinked viewer on a server with the switch + // on, as the web does: anyone else could not act on it. + LegendRow( + stringResource(R.string.rust_map_layer_mates), + SWATCHES.getValue(MapLayer.MATES), + false, + false, + stringResource(R.string.rust_map_mates_link), + ) {} + } +} + +@Composable +private fun hiddenNote(gate: RustMapLayerDto): String = stringResource( + when (gate.audience) { + "signed_in" -> R.string.rust_map_hidden_signin + "public" -> R.string.rust_map_hidden_public + else -> R.string.rust_map_hidden_staff + }, +) + +@Composable +private fun LegendRow( + label: String, + swatches: List, + enabled: Boolean, + checked: Boolean, + note: String?, + onToggle: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch(checked = checked, onCheckedChange = { onToggle() }, enabled = enabled) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + swatches.forEach { + Spacer(Modifier.width(6.dp)) + Box(Modifier.size(10.dp).background(it, CircleShape)) + } + } + note?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun ColumnScope.Note(text: String) { + if (text.isBlank()) return + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) +} + +/** The line under the map, in the web's words (`liveStatus` in `MapView.jsx`). */ +@Composable +private fun liveStatus(map: RustMapDto, ui: RustMapUi, serverOnline: Boolean): String { + if (!map.anyLive) return stringResource(R.string.rust_map_status_nothing_live) + val live = ui.live + if (live == null) { + return stringResource(if (ui.liveFailed) R.string.rust_map_status_failed else R.string.rust_map_status_asking) + } + if (ui.liveFailed) return stringResource(R.string.rust_map_status_failed_kept) + if (!live.live) { + return stringResource(if (serverOnline) R.string.rust_map_status_not_now else R.string.rust_map_status_offline) + } + val at = ui.liveAt ?: return "" + return stringResource(R.string.rust_map_status_as_of, CLOCK.format(Instant.ofEpochMilli(at))) +} + +private val CLOCK: DateTimeFormatter = + DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault()) + +private fun com.runicgateway.app.data.api.dto.RustMapLayersDto.of(layer: MapLayer): RustMapLayerDto = when (layer) { + MapLayer.WORLD -> world + MapLayer.EVENTS -> events + MapLayer.PLAYERS -> players + MapLayer.BASES -> bases + else -> RustMapLayerDto() +} + +/** `#RRGGBB` as the plugin sends it, or null for anything else. */ +internal fun parseColour(value: String?): Color? { + val hex = value?.trim()?.removePrefix("#") ?: return null + if (hex.length != 6) return null + val rgb = hex.toLongOrNull(16) ?: return null + return Color(0xFF000000 or rgb) +} + +private val TAP_REACH = 24.dp +private val LABEL_MIN_CELL = 30.dp + +// The web's palette (`MapView.jsx` COLOURS), so a reader moving between the two +// reads the same marker as the same thing. +private val OCEAN = Color(0xFF0B3B4A) +private val SURROUND = Color(0xFF071F27) +private val GRID_LINE = Color.White.copy(alpha = 0.18f) +private val GRID_LABEL = Color.White.copy(alpha = 0.55f) +private val MONUMENT = Color(0xFFE8D9A8) +private val EVENT = Color(0xFFCE93D8) +private val ONLINE = Color(0xFFFFFFFF) +private val SLEEPING = Color(0xFF9E9E9E) +private val TC = Color(0xFFFF7043) +private val VENDING = Color(0xFF26A69A) +private val SELF = Color(0xFF00E5FF) +private val MATE = Color(0xFF7CFFB2) +private val WORLD_COLOURS = mapOf( + "cargo" to Color(0xFF4FC3F7), + "heli" to Color(0xFFEF5350), + "chinook" to Color(0xFFFFA726), + "bradley" to Color(0xFFA1887F), + "supply" to Color(0xFF66BB6A), + "crate" to Color(0xFFFFEE58), +) + +private val LAYER_ROWS = listOf( + MapLayer.WORLD to R.string.rust_map_layer_world, + MapLayer.EVENTS to R.string.rust_map_layer_events, + MapLayer.PLAYERS to R.string.rust_map_layer_players, + MapLayer.BASES to R.string.rust_map_layer_bases, +) + +private val SWATCHES = mapOf( + MapLayer.WORLD to listOf(MONUMENT, WORLD_COLOURS.getValue("cargo"), WORLD_COLOURS.getValue("heli"), WORLD_COLOURS.getValue("crate")), + MapLayer.EVENTS to listOf(EVENT), + MapLayer.PLAYERS to listOf(ONLINE, SLEEPING), + MapLayer.BASES to listOf(TC, VENDING), + MapLayer.MATES to listOf(SELF, MATE), +) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt new file mode 100644 index 0000000..5c4b016 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt @@ -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 = UiState.Loading, + /** The last live answer, or null before the first. Kept when a later ask fails. */ + val live: RustMapLiveDto? = null, + /** True when the most recent ask failed; [live] is then the one before it. */ + val liveFailed: Boolean = false, + /** When [live] arrived, epoch ms. */ + val liveAt: Long? = null, + /** The legend's switches: the reader's convenience, never a boundary. */ + val shown: Set = MapLayer.entries.toSet(), + val selected: MapMarker? = null, + /** Run id → event slug, for the runs core's public calendar lists. */ + val eventSlugs: Map = emptyMap(), +) + +/** + * One server's map (`docs/modules/rust/PLAN.md` §31, D121–D125; PLAN.md M17). + * + * ## It draws what it is sent + * + * `map` and `map/live` are projected per viewer **on the server**, from the same + * bearer token every other request carries. A layer this viewer may not see is + * absent from the answer, so nothing here decides who sees what. + * + * ## Keyed on the signed-in account + * + * Mates and the players layer are per viewer, and this view model lives as long + * as the server screen's back-stack entry, across a sign-out and a sign-in as + * somebody else. Events phase 14b found what that costs: a view model that loads + * once showed the next account the previous one's answer without asking. So the + * account is what this keys on. A change of account drops everything drawn, + * including the tapped card, and asks again, and an answer to a question asked + * for the previous account is thrown away when it lands (the [generation]). + * Signing out is a change too: the map is public, so a signed-out reader gets + * the public map rather than an empty one. + * + * ## When it asks + * + * `map` once when the tab opens, and again when a live answer names a different + * `mapKey`: a wipe or a new seed, whose picture follows without leaving the tab. + * `map/live` on the screen's poll (D124), and not at all when the server sends + * this viewer no moving layer. + */ +@HiltViewModel +class RustMapViewModel @Inject constructor( + private val repository: RustRepository, + events: EventsRepository, + sessionManager: SessionManager, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: "" + + private val resolver = EventRunResolver(events) + + private val _state = MutableStateFlow(RustMapUi()) + val state: StateFlow = _state.asStateFlow() + + /** Bumped on every change of account; an answer from an older one is dropped. */ + private var generation = 0 + + /** A poll still waiting for its answer: the next tick does not stack another. */ + private var polling = false + + init { + viewModelScope.launch { + sessionManager.state + .map { (it as? Session.SignedIn)?.user?.id } + .distinctUntilChanged() + .collect { + generation++ + polling = false + _state.update { s -> RustMapUi(shown = s.shown) } + load() + } + } + } + + /** The map's picture, frame and gates. A retry after a failure too. */ + fun load() { + val asked = generation + _state.update { it.copy(map = UiState.Loading) } + viewModelScope.launch { askMap(asked) } + } + + /** + * The poll tick. Asks only once the map has a frame to place things in and + * only when this viewer is sent something that moves. + */ + fun poll() { + val map = (_state.value.map as? UiState.Success)?.data ?: return + if (map.geometry == null || !map.anyLive || polling) return + + val asked = generation + polling = true + viewModelScope.launch { + val result = repository.mapLive(serverId) + if (asked != generation) return@launch + polling = false + + when (result) { + is ApiResult.Ok -> { + val answer = result.data + _state.update { + it.copy(live = answer, liveFailed = false, liveAt = System.currentTimeMillis()) + } + // A new map under the same server: the picture follows. + if (answer.mapKey != null && map.mapKey != null && answer.mapKey != map.mapKey) { + askMap(asked) + } + resolveRuns(answer, asked) + } + // Positions are kept on a failure, in Polling.kt's shape: the last + // answer stays drawn and the status line says the ask failed. + else -> _state.update { it.copy(liveFailed = true) } + } + } + } + + fun toggle(layer: MapLayer) { + _state.update { + val shown = if (layer in it.shown) it.shown - layer else it.shown + layer + // A card for something the reader just hid would describe a marker + // that is no longer on the map. + val selected = it.selected?.takeIf { m -> m.layer in shown } + it.copy(shown = shown, selected = selected) + } + } + + fun select(marker: MapMarker?) { + _state.update { it.copy(selected = marker) } + } + + private suspend fun askMap(asked: Int) { + val result = repository.map(serverId) + if (asked != generation) return + _state.update { current -> + // A failed RE-read after a new mapKey keeps the map that is drawn: + // the old picture under new positions is better than an error screen, + // and the next poll asks again. + if (result !is ApiResult.Ok && current.map is UiState.Success) current + else current.copy(map = result.toUiState()) + } + } + + private suspend fun resolveRuns(answer: RustMapLiveDto, asked: Int) { + val ids = eventRunIds(answer) + if (ids.isEmpty()) return + val slugs = resolver.resolve(ids) + 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 diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index b50845f..522166b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -52,7 +52,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 @@ -66,6 +67,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(), ) { @@ -89,7 +91,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) } } @@ -119,12 +121,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( @@ -144,6 +147,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, @@ -159,6 +163,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 } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt index a1c9677..6480d03 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt @@ -11,7 +11,10 @@ import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustOnlineDto import com.runicgateway.app.data.api.dto.RustServerDto import com.runicgateway.app.data.api.dto.RustWipeDto +import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.data.repository.SiteCapabilitiesRepository +import com.runicgateway.app.data.repository.canUse import com.runicgateway.app.ui.Polled import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.navigation.Routes @@ -25,9 +28,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -/** The four sections of a server's page (D13). */ +/** + * 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, WIPES; + FEED, LEADERBOARD, ONLINE, MAP, WIPES; /** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */ val wire: String get() = name.lowercase() @@ -71,7 +78,12 @@ data class RustServerUi( val online: Polled = Polled(), val leaderboard: UiState> = UiState.Loading, val wipes: UiState> = UiState.Loading, -) + /** Whether the Map tab is offered, under the app's one capability rule (D122). */ + val mapAvailable: Boolean = true, +) { + /** The tabs this site offers, in order. */ + val tabs: List get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable } +} /** * One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14). @@ -99,6 +111,7 @@ data class RustServerUi( @HiltViewModel class RustServerViewModel @Inject constructor( private val repository: RustRepository, + capabilities: SiteCapabilitiesRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -112,6 +125,19 @@ class RustServerViewModel @Inject constructor( 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) } @@ -137,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 } } } @@ -152,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 { @@ -160,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 } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8f846f..f0e7676 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -586,6 +586,55 @@ Leaderboard Online Wipes + Map + + No map yet. This server has not told the site which map it is on. The map will appear once the server is up. + This server has no picture of its map, so the layers are drawn on a plain background. + The picture of the map could not be loaded, so the layers are drawn on a plain background. + Map of the server + No moving layers are shown to you on this server. + Asking the server where things are… + The server did not say where things are just now. The site asks again every ten seconds. + The server is offline, so nothing is moving on its map. + Positions could not be loaded. + Could not refresh just now. These are the last positions the site heard. + Positions as of %1$s. They refresh every ten seconds while this tab is open. + Grid + Monuments & world events + Site events + Players + Bases + You and your clan + Sign in to see this layer. + Shown to everyone. + Shown to staff only. + %1$s Limited by who may see who is online. + Not every sleeper is shown. + Not every base is shown. + Your own position, and clan mates who are online. + Link your Steam account to see yourself and your clan here. + Cargo ship + Patrol helicopter + Chinook + Bradley APC + Supply drop + Locked crate + %1$s left on the hack + Hacked + Event zone + Event crate + Event NPC + Site event + Open event + Tool cupboard + Vending machine + Sleeping + Asleep, offline + You + Clan mate + Grid %1$s + Close All time %1$s (this wipe) Current diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt index 2003373..fc979d5 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt @@ -38,8 +38,13 @@ class FakeEventsApi : EventsApi { return value } - override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto = - reply(calendar) + /** How many times the calendar was read, for the Rust map's run resolver. */ + var calendarCalls: Int = 0 + + override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto { + calendarCalls++ + return reply(calendar) + } override suspend fun getEvent(slug: String, run: String?): PublicEventResponse { lastSlug = slug diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt index 93fcfa9..65b03df 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt @@ -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 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 + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt new file mode 100644 index 0000000..8fcc6c1 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt @@ -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(), resolver.resolve(setOf("99"))) + } + + @Test + fun `a projection and a core older than D125 name no run`() = runTest { + api.calendar = EventCalendarDto( + listOf( + EventCalendarEntryDto(kind = "projected", slug = "weekly"), + run(null, "old-core"), + ), + ) + assertEquals(emptyMap(), resolver.resolve(setOf("41"))) + } + + @Test + fun `known ids never ask again`() = runTest { + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + repeat(5) { resolver.resolve(setOf("41")) } + clock += 10 * EventRunResolver.REFRESH_MS + resolver.resolve(setOf("41")) + assertEquals(1, api.calendarCalls) + } + + @Test + fun `an unknown id re-reads at most once a minute`() = runTest { + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + resolver.resolve(setOf("41", "42")) + assertEquals(1, api.calendarCalls) + + // Every ten-second poll inside the minute: no new read. + repeat(5) { + clock += 10_000 + resolver.resolve(setOf("41", "42")) + } + assertEquals(1, api.calendarCalls) + + // Past the minute, the new run is found. + clock += 10_000 + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"), run(42, "crate-rush"))) + assertEquals(mapOf("41" to "harbor-brawl", "42" to "crate-rush"), resolver.resolve(setOf("41", "42"))) + assertEquals(2, api.calendarCalls) + } + + @Test + fun `a failed read keeps what the last one found, and still counts as a read`() = runTest { + api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"))) + resolver.resolve(setOf("41")) + + api.error = IOException("offline") + clock += EventRunResolver.REFRESH_MS + assertEquals(mapOf("41" to "harbor-brawl"), resolver.resolve(setOf("41", "42"))) + assertEquals(2, api.calendarCalls) + + // Not asked again on the very next poll. + clock += 10_000 + resolver.resolve(setOf("41", "42")) + assertEquals(2, api.calendarCalls) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt new file mode 100644 index 0000000..6564427 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt @@ -0,0 +1,213 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121). + * + * **These are the web's cases** (`module-rust/client/test/mapGeometry.test.js`), + * so the phone and the page cannot put the same crate in two squares. The grid + * cases are the GAME's answers: on 2026-09-25 a probe on the Oxide rig (a 3000 + * map, seed 1234) asked `MapHelper.PositionToString` for these positions and + * wrote down what it said. + * + * One difference from the web, and it is deliberate: a canvas grows DOWN, so + * [MapFrame.toPixel] answers y measured from the top, where Leaflet's frame + * measures it up from the bottom. + */ +class MapFrameTest { + + /** The rig's map, as `GET /map` describes it. */ + private val rig = MapFrame( + RustMapGeometryDto( + worldSize = 3000.0, + oceanMargin = 500.0, + width = 2500.0, + height = 2500.0, + gridCells = 20, + gridCellSize = 150.0, + ), + ) + + @Test + fun `the grid label is the game's, at every probed position`() { + val probed = listOf( + Triple("ue_jungle_swamp_a", 764.7 to 167.4, "P8"), + Triple("ue_jungle_swamp_a", 733.7 to -556.0, "O13"), + Triple("harbor_2", 1122.7 to 204.6, "R8"), + Triple("harbor_1", 678.1 to 1005.7, "O3"), + Triple("ferry_terminal_1", 645.4 to -1004.1, "O16"), + Triple("fishing_village_a", -787.8 to 224.1, "E8"), + Triple("fishing_village_c", -203.0 to -911.1, "I16"), + Triple("fishing_village_b", 1142.1 to -566.5, "R13"), + Triple("desert_military_base_c", 94.0 to -729.3, "K14"), + Triple("arctic_research_base_a", -556.9 to 840.0, "G4"), + Triple("powerplant_1", -608.6 to -346.4, "F12"), + Triple("water_treatment_plant_1", 497.8 to 84.9, "N9"), + Triple("nw-corner", -1499.0 to 1499.0, "A0"), + Triple("se-corner", 1499.0 to -1499.0, "T19"), + Triple("origin", 0.0 to 0.0, "K10"), + ) + for ((name, at, game) in probed) assertEquals(name, game, rig.gridLabel(at.first, at.second)) + } + + @Test + fun `a 146 point 3 m cell, phase 3's constant, would have disagreed with the game`() { + val wrong = MapFrame(rig.geometry.copy(gridCells = 21, gridCellSize = 146.3)) + assertNotEquals("O16", wrong.gridLabel(645.4, -1004.1)) + } + + @Test + fun `columns past Z are spelled the way Rust spells them`() { + assertEquals("A", MapFrame.column(0)) + assertEquals("Z", MapFrame.column(25)) + assertEquals("AA", MapFrame.column(26)) + assertEquals("AB", MapFrame.column(27)) + } + + @Test + fun `the ocean margin is in pixels and is not scaled`() { + assertEquals(0.5, rig.scale, 0.0) + // The world's corners sit exactly one margin inside the picture's; the + // south-west corner is at the BOTTOM left of a canvas. + assertEquals(PicturePoint(500.0, 2000.0), rig.toPixel(-1500.0, -1500.0)) + assertEquals(PicturePoint(2000.0, 500.0), rig.toPixel(1500.0, 1500.0)) + assertEquals(PicturePoint(1250.0, 1250.0), rig.toPixel(0.0, 0.0)) + } + + @Test + fun `north is up, so a larger z is higher on the screen, and x is across`() { + val low = rig.toPixel(100.0, 100.0) + val high = rig.toPixel(100.0, 400.0) + assertTrue(high.y < low.y) + assertEquals(low.x, high.x, 0.0) + } + + @Test + fun `something off the edge of the world is still placed, outside the picture`() { + // The rig's cargo ship, as the probe found it: past the world AND the margin. + val p = rig.toPixel(2691.6, -1453.1) + assertTrue(p.x > rig.width) + assertTrue(p.y > 0 && p.y < rig.height) + } + + @Test + fun `the app cannot assume the rig's size`() { + // A 4500 m world is a 3250 px picture (§31.1), and its origin is its centre. + val big = MapFrame( + RustMapGeometryDto(worldSize = 4500.0, oceanMargin = 500.0, width = 3250.0, height = 3250.0), + ) + assertEquals(PicturePoint(1625.0, 1625.0), big.toPixel(0.0, 0.0)) + assertEquals(PicturePoint(500.0, 2750.0), big.toPixel(-2250.0, -2250.0)) + } + + @Test + fun `the grid has a line per edge and a label per cell, A0 at the north-west corner`() { + val g = rig.grid() + assertEquals(2 * (20 + 1), g.lines.size) + assertEquals(20 * 20, g.labels.size) + val a0 = g.labels.first { it.text == "A0" } + assertEquals(-1500.0, a0.x, 0.0) + assertEquals(1500.0, a0.z, 0.0) + assertEquals(MapGrid(emptyList(), emptyList()), MapFrame(RustMapGeometryDto()).grid()) + } + + @Test + fun `a geometry that cannot place anything places nothing rather than NaN everywhere`() { + val none = MapFrame(RustMapGeometryDto(worldSize = 0.0, width = 2500.0)) + assertEquals(0.0, none.scale, 0.0) + assertFalse(none.canPlace) + assertNull(MapFrame(RustMapGeometryDto(worldSize = 3000.0)).gridLabel(0.0, 0.0)) + } + + @Test + fun `a hack timer reads as minutes and seconds`() { + assertEquals("9:00", countdown(540)) + assertEquals("1:01", countdown(61.4)) + assertEquals("0:00", countdown(-3)) + assertEquals("0:00", countdown(null)) + } + + @Test + fun `the picture's path is joined under api v1 as a relative path`() { + // Relative, like every Retrofit path here, so a site under a prefix keeps it. + assertEquals( + "api/v1/public/rust/servers/main/map/image?v=28da6e8a", + mapPictureUrl("/public/rust/servers/main/map/image?v=28da6e8a"), + ) + } + + // ── The screen transform ─────────────────────────────────────────────── + + private val phone = ViewSize(1080f, 1500f) + private val picture = ViewSize(2500f, 2500f) + + @Test + fun `fit shows the whole picture, centred`() { + val t = MapTransform.fit(phone, picture) + assertEquals(1080f / 2500f, t.scale, 1e-6f) + assertEquals(0f, t.offsetX, 1e-3f) + assertEquals((1500f - 1080f) / 2, t.offsetY, 1e-3f) + } + + @Test + fun `zoom stops at fit and at four times the picture's own pixels`() { + val fit = MapTransform.fit(phone, picture) + val tooFar = fit.transformed(phone, picture, 540f, 750f, 0f, 0f, 1000f) + assertEquals(MapTransform.MAX_ZOOM, tooFar.scale, 0f) + val tooNear = tooFar.transformed(phone, picture, 540f, 750f, 0f, 0f, 0.0001f) + assertEquals(fit.scale, tooNear.scale, 1e-6f) + } + + @Test + fun `a pinch keeps the picture pixel under the fingers where it was`() { + val fit = MapTransform.fit(phone, picture) + val px = fit.pictureX(300f) + val py = fit.pictureY(900f) + val zoomed = fit.transformed(phone, picture, 300f, 900f, 0f, 0f, 2f) + assertEquals(px, zoomed.pictureX(300f), 1e-3) + assertEquals(py, zoomed.pictureY(900f), 1e-3) + } + + @Test + fun `a pan cannot lose the map, but may follow something a little past its edge`() { + val zoomed = MapTransform.fit(phone, picture).transformed(phone, picture, 540f, 750f, 0f, 0f, 4f) + val flung = zoomed.transformed(phone, picture, 540f, 750f, -1e7f, -1e7f, 1f) + // The view's centre stays within the picture plus a quarter of it. + assertEquals(2500.0 * 1.25, flung.pictureX(540f), 1e-2) + assertEquals(2500.0 * 1.25, flung.pictureY(750f), 1e-2) + } + + @Test + fun `a box that grows keeps the reader's zoom and the place they were looking at`() { + // The walk: the status line under the map went from two lines to one, the + // box grew, and the zoom was thrown away. + val zoomed = MapTransform.fit(phone, picture).transformed(phone, picture, 300f, 900f, 0f, 0f, 4f) + val taller = ViewSize(1080f, 1560f) + val centreBefore = zoomed.pictureX(540f) to zoomed.pictureY(750f) + + val after = zoomed.resized(phone, taller, picture) + + assertEquals(zoomed.scale, after.scale, 0f) + assertEquals(centreBefore.first, after.pictureX(540f), 1e-2) + assertEquals(centreBefore.second, after.pictureY(780f), 1e-2) + } + + @Test + fun `a box that grows past the old zoom's floor is clamped up, not refitted from scratch`() { + // Fitted to a small box, the scale is below what a larger box allows. + val small = ViewSize(540f, 750f) + val fitSmall = MapTransform.fit(small, picture) + val after = fitSmall.resized(small, phone, picture) + assertEquals(MapTransform.minScale(phone, picture), after.scale, 1e-6f) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt new file mode 100644 index 0000000..2b13ba4 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt @@ -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(), eventRunIds(RustMapLiveDto())) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt new file mode 100644 index 0000000..017539b --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt @@ -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("""{"live":true,"mapKey":"3000.1234.1"}""") + assertTrue(live.live) + assertNull(live.world) + assertNull(live.events) + assertNull(live.players) + assertNull(live.bases) + assertNull(live.mates) + assertEquals(emptyList(), mapMarkers(RustMapDto(), live, MapLayer.entries.toSet())) + } + + @Test + fun `an empty layer is present, and different from an absent one`() { + val live = json.decodeFromString("""{"live":true,"world":[],"players":null}""") + assertNotNull(live.world) + assertTrue(live.world!!.isEmpty()) + assertNull(live.players) + } + + @Test + fun `a game that did not answer keeps its reason`() { + val live = json.decodeFromString("""{"live":false,"reason":"timeout"}""") + assertFalse(live.live) + assertEquals("timeout", live.reason) + } + + @Test + fun `the map as the rig answers it`() { + val map = json.decodeFromString( + """ + {"serverId":"main","mapKey":"3000.1234.1", + "picture":{"path":"/public/rust/servers/main/map/image?v=28da6e8a","source":"companion","fetchedAt":null}, + "geometry":{"worldSize":3000,"oceanMargin":500,"width":2500,"height":2500,"gridCells":20,"gridCellSize":150,"background":"#0B3B4A"}, + "monuments":[{"value":"harbor_1#2","kind":"harbor_1","label":"Harbor","grid":"O3","x":678.1,"z":1005.7}], + "layers":{"world":{"visible":true,"audience":"public"},"events":{"visible":true,"audience":"public"}, + "players":{"visible":false,"audience":"staff","cappedByPresence":true},"bases":{"visible":false,"audience":"staff"}}, + "mates":{"visible":false,"on":true,"linked":false,"signedIn":true}, + "pollMs":10000} + """.trimIndent(), + ) + assertEquals(2500.0, map.geometry!!.width, 0.0) + assertEquals("O3", map.monuments!!.single().grid) + assertTrue(map.layers.players.cappedByPresence) + assertFalse(map.layers.players.visible) + assertTrue(map.anyLive) + assertEquals(10_000L, map.pollMs) + } + + @Test + fun `a server that never described its map has no geometry and no picture`() { + val map = json.decodeFromString("""{"serverId":"main","mapKey":null,"picture":null,"geometry":null}""") + assertNull(map.geometry) + assertNull(map.picture) + assertFalse(map.anyLive) + } + + @Test + fun `a site event's run id arrives as the plugin's string`() { + val live = json.decodeFromString( + """{"live":true,"events":[{"kind":"zone","runId":"41","x":1,"z":2,"radius":60,"name":"Harbor brawl"}]}""", + ) + assertEquals("41", live.events!!.single().runId) + } + + @Test + fun `a calendar run carries core's run id as a number, and a projection none`() { + // D125. A core older than it omits the field, which decodes to null. + val calendar = json.decodeFromString( + """{"entries":[{"kind":"run","runId":41,"slug":"a"},{"kind":"projected","slug":"b"},{"kind":"run","slug":"c"}]}""", + ) + assertEquals(listOf(41L, null, null), calendar.entries.map { it.runId }) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt new file mode 100644 index 0000000..b1c7781 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapViewModelTest.kt @@ -0,0 +1,258 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.rust + +import androidx.lifecycle.SavedStateHandle +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.auth.StoredSession +import com.runicgateway.app.core.auth.TokenStore +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventCalendarEntryDto +import com.runicgateway.app.data.api.dto.RustMapDto +import com.runicgateway.app.data.api.dto.RustMapEventDto +import com.runicgateway.app.data.api.dto.RustMapGeometryDto +import com.runicgateway.app.data.api.dto.RustMapLayerDto +import com.runicgateway.app.data.api.dto.RustMapLayersDto +import com.runicgateway.app.data.api.dto.RustMapLiveDto +import com.runicgateway.app.data.api.dto.RustMapMatesDto +import com.runicgateway.app.data.api.dto.RustMapPlayerDto +import com.runicgateway.app.data.api.dto.RustMapWorldDto +import com.runicgateway.app.data.api.dto.SafeUserDto +import com.runicgateway.app.data.api.fake.FakeEventsApi +import com.runicgateway.app.data.api.fake.FakeRustApi +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.data.repository.RustRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** One server's map: when it asks, and whose answer it draws (§31.2). */ +class RustMapViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val api = FakeRustApi() + private val eventsApi = FakeEventsApi() + + private class FakeTokenStore(private var stored: StoredSession?) : TokenStore { + override fun load(): StoredSession? = stored + override fun save(session: StoredSession) { stored = session } + override fun clear() { stored = null } + } + + private fun sessionFor(userId: Long?) = SessionManager( + FakeTokenStore(userId?.let { StoredSession("a", "r", it, "u$it", "player") }), + ) + + private fun user(id: Long) = SafeUserDto(id = id, username = "u$id", role = "player") + + private fun viewModel(sessions: SessionManager = sessionFor(null)) = RustMapViewModel( + RustRepository(api), + EventsRepository(eventsApi), + sessions, + SavedStateHandle(mapOf("serverId" to "main")), + ) + + private val geometry = RustMapGeometryDto(worldSize = 3000.0, oceanMargin = 500.0, width = 2500.0, height = 2500.0) + private val public = RustMapLayerDto(visible = true, audience = "public") + private val staffOnly = RustMapLayerDto(visible = false, audience = "staff") + + private fun map(key: String = "3000.1234.1", mates: Boolean = false) = RustMapDto( + serverId = "main", + mapKey = key, + geometry = geometry, + layers = RustMapLayersDto(world = public, events = public, players = staffOnly, bases = staffOnly), + mates = RustMapMatesDto(visible = mates, on = true, linked = mates, signedIn = mates), + pollMs = 10_000, + ) + + private fun mine(steamId: String) = RustMapLiveDto( + live = true, + mapKey = "3000.1234.1", + mates = listOf(RustMapPlayerDto(steamId = steamId, x = 1.0, z = 1.0, online = true, self = true)), + ) + + @Test + fun `it reads the map once and asks nothing that moves until the poll`() { + api.map = map() + val vm = viewModel() + assertTrue(vm.state.value.map is UiState.Success) + assertEquals(1, api.mapCalls) + assertEquals(0, api.mapLiveCalls) + + vm.poll() + assertEquals(1, api.mapLiveCalls) + } + + @Test + fun `a viewer sent nothing that moves is never polled`() { + api.map = map().copy(layers = RustMapLayersDto(world = staffOnly, events = staffOnly, players = staffOnly, bases = staffOnly)) + val vm = viewModel() + vm.poll() + assertEquals(0, api.mapLiveCalls) + } + + @Test + fun `a server that never described its map is not polled`() { + api.map = map().copy(geometry = null) + val vm = viewModel() + vm.poll() + assertEquals(0, api.mapLiveCalls) + } + + @Test + fun `a module older than phase 14 is an error with a retry, not a crash`() { + api.error = httpError(404) + val vm = viewModel() + assertTrue(vm.state.value.map is UiState.Error) + } + + @Test + fun `a failed poll keeps the last positions drawn`() { + api.map = map() + api.mapLive = RustMapLiveDto(live = true, world = listOf(RustMapWorldDto("cargo", 1.0, 1.0))) + val vm = viewModel() + vm.poll() + + api.mapLiveQueue.add { throw IOException("offline") } + vm.poll() + + assertEquals("cargo", vm.state.value.live?.world?.single()?.kind) + assertTrue(vm.state.value.liveFailed) + } + + @Test + fun `a new map key re-reads the map without leaving the tab`() { + api.map = map(key = "3000.1234.1") + val vm = viewModel() + + api.map = map(key = "3000.5678.2") + api.mapLive = RustMapLiveDto(live = true, mapKey = "3000.5678.2") + vm.poll() + + assertEquals(2, api.mapCalls) + assertEquals("3000.5678.2", (vm.state.value.map as UiState.Success).data.mapKey) + } + + @Test + fun `the same map key does not re-read the map`() { + api.map = map() + api.mapLive = RustMapLiveDto(live = true, mapKey = "3000.1234.1") + val vm = viewModel() + repeat(3) { vm.poll() } + assertEquals(1, api.mapCalls) + } + + // ── Per account ──────────────────────────────────────────────────────── + + @Test + fun `another account sees none of the first account's dots before its own answer`() { + // Walk step 3, as a test. The server screen's back-stack entry outlives a + // sign-out, and events phase 14b found a view model that loads once shows + // the next account the previous one's answer without asking. + val sessions = sessionFor(33) + api.map = map(mates = true) + api.mapLive = mine("first") + val vm = viewModel(sessions) + vm.poll() + assertEquals("first", vm.state.value.live?.mates?.single()?.steamId) + + sessions.onSignedIn("a2", "r2", user(34)) + + // Nothing of the first account's is left drawn, and the map was asked again. + assertNull(vm.state.value.live) + assertNull(vm.state.value.selected) + assertEquals(2, api.mapCalls) + + api.mapLive = mine("second") + vm.poll() + assertEquals("second", vm.state.value.live?.mates?.single()?.steamId) + } + + @Test + fun `an answer asked for the previous account is thrown away when it lands`() { + val sessions = sessionFor(33) + api.map = map(mates = true) + val vm = viewModel(sessions) + + val gate = CompletableDeferred() + api.mapLiveQueue.add { + gate.await() + mine("first") + } + vm.poll() + + sessions.onSignedIn("a2", "r2", user(34)) + gate.complete(Unit) + + assertNull(vm.state.value.live) + } + + @Test + fun `signing out still shows the public map, not an empty one`() { + val sessions = sessionFor(33) + api.map = map(mates = true) + api.mapLive = mine("first") + val vm = viewModel(sessions) + vm.poll() + + api.map = map(mates = false) + sessions.onSignedOut() + + assertNull(vm.state.value.live) + assertTrue(vm.state.value.map is UiState.Success) + assertEquals(false, (vm.state.value.map as UiState.Success).data.mates.visible) + } + + @Test + fun `the reader's switches survive a change of account`() { + val sessions = sessionFor(33) + api.map = map() + val vm = viewModel(sessions) + vm.toggle(MapLayer.GRID) + + sessions.onSignedIn("a2", "r2", user(34)) + + assertTrue(MapLayer.GRID !in vm.state.value.shown) + } + + // ── The event link ───────────────────────────────────────────────────── + + @Test + fun `a listed run's marker resolves to its event, and a rehearsal's does not`() { + api.map = map() + api.mapLive = RustMapLiveDto( + live = true, + events = listOf( + RustMapEventDto(kind = "zone", runId = "41", radius = 60.0, name = "Harbor brawl"), + // A rehearsal: absent from the public calendar. + RustMapEventDto(kind = "zone", runId = "77", radius = 60.0), + ), + ) + eventsApi.calendar = EventCalendarDto(listOf(EventCalendarEntryDto(kind = "run", runId = 41, slug = "harbor-brawl"))) + val vm = viewModel() + vm.poll() + + assertEquals(mapOf("41" to "harbor-brawl"), vm.state.value.eventSlugs) + } + + @Test + fun `hiding a layer closes the card for a marker on it`() { + api.map = map() + val vm = viewModel() + vm.select(MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41")) + + vm.toggle(MapLayer.EVENTS) + + assertNull(vm.state.value.selected) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt index 03116f6..ce3ee6b 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt @@ -4,6 +4,8 @@ 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 @@ -13,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 @@ -36,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 @@ -220,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) + } } -- 2.49.1