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