Merge pull request 'feat(rust): the map on a phone (module-rust phase 15, M17)' (#51) from feat/rust-phase-15-map into edge
Reviewed-on: #51
This commit is contained in:
@@ -5,6 +5,8 @@ package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
@@ -91,4 +93,19 @@ interface RustApi {
|
||||
/** The presence board, which an unreachable server does not clear. */
|
||||
@GET("api/v1/public/rust/servers/{id}/online")
|
||||
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
||||
|
||||
/**
|
||||
* The map: where its picture is, the frame to draw it in, and which layers
|
||||
* this viewer gets (Rust phase 14). A module older than phase 14 answers 404.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map")
|
||||
suspend fun getMap(@Path("id") id: String): RustMapDto
|
||||
|
||||
/**
|
||||
* What moves, already cut down to this viewer on the server: a layer they
|
||||
* may not see is absent. The module asks the game at most once per five
|
||||
* seconds per server, however many viewers there are (D111).
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map/live")
|
||||
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
|
||||
}
|
||||
|
||||
@@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||
*
|
||||
* [runId] is on a run and never on a projection, which has nothing committed to
|
||||
* it. Core added it for Rust D125, so a map marker naming a run can find its
|
||||
* event; a core older than that omits it, and the marker stays unlinked.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventCalendarEntryDto(
|
||||
val kind: String = "run",
|
||||
val runId: Long? = null,
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val seriesName: String? = null,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* DTOs for `module-rust`'s map (`docs/modules/rust/PLAN.md` §30, §31; M17).
|
||||
*
|
||||
* **Who may see what is decided on the server, and these shapes only carry the
|
||||
* answer.** Each of the four layers has its own audience, and the module removes
|
||||
* a layer the viewer may not see before it answers: the layer is *absent*, not
|
||||
* empty and not flagged. That is why every layer on [RustMapLiveDto] is nullable.
|
||||
* A null list is "not yours"; an empty one is "yours, and nothing is there".
|
||||
* The app has no gate of its own, so it has none to get wrong.
|
||||
*/
|
||||
|
||||
/** `GET /public/rust/servers/{id}/map`: the picture, the frame, and which layers this viewer gets. */
|
||||
@Serializable
|
||||
data class RustMapDto(
|
||||
val serverId: String = "",
|
||||
/** Changes on a wipe or a new seed. A live answer naming another key means a new map. */
|
||||
val mapKey: String? = null,
|
||||
/** Null when the game has no picture: the layers are drawn on [RustMapGeometryDto.background]. */
|
||||
val picture: RustMapPictureDto? = null,
|
||||
/** Null when the server has never described its map. Nothing can be placed without it. */
|
||||
val geometry: RustMapGeometryDto? = null,
|
||||
/** Present only when the viewer may see the world layer. */
|
||||
val monuments: List<RustMonumentDto>? = null,
|
||||
val layers: RustMapLayersDto = RustMapLayersDto(),
|
||||
val mates: RustMapMatesDto = RustMapMatesDto(),
|
||||
val pollMs: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Where the picture is.
|
||||
*
|
||||
* [path] is relative to `/api/v1` and carries the picture's hash, so it is
|
||||
* immutable: a hash that is no longer current is a 404, never the new bytes.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapPictureDto(
|
||||
val path: String = "",
|
||||
val source: String? = null,
|
||||
val fetchedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* How a world position reaches a pixel (§30.3). [oceanMargin] is in pixels and is
|
||||
* not scaled; [gridCells] and [gridCellSize] are the game's own grid (D119).
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapGeometryDto(
|
||||
val worldSize: Double = 0.0,
|
||||
val oceanMargin: Double = 0.0,
|
||||
val width: Double = 0.0,
|
||||
val height: Double = 0.0,
|
||||
val gridCells: Int = 0,
|
||||
val gridCellSize: Double = 0.0,
|
||||
val background: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RustMonumentDto(
|
||||
val value: String = "",
|
||||
val kind: String = "",
|
||||
val label: String = "",
|
||||
val grid: String? = null,
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RustMapLayersDto(
|
||||
val world: RustMapLayerDto = RustMapLayerDto(),
|
||||
val events: RustMapLayerDto = RustMapLayerDto(),
|
||||
val players: RustMapLayerDto = RustMapLayerDto(),
|
||||
val bases: RustMapLayerDto = RustMapLayerDto(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this viewer gets one layer, and who does. A hidden layer never says
|
||||
* what it holds. [cappedByPresence] is the players layer's alone: it is narrower
|
||||
* than its own switch because who may see who is online is narrower (D113).
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapLayerDto(
|
||||
val visible: Boolean = false,
|
||||
val audience: String? = null,
|
||||
val cappedByPresence: Boolean = false,
|
||||
)
|
||||
|
||||
/** Whether this viewer gets their own position and their online clan mates' (D115). */
|
||||
@Serializable
|
||||
data class RustMapMatesDto(
|
||||
val visible: Boolean = false,
|
||||
/** The server's switch. */
|
||||
val on: Boolean = false,
|
||||
val linked: Boolean = false,
|
||||
val signedIn: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/rust/servers/{id}/map/live`: what moves, cut down to this viewer.
|
||||
*
|
||||
* [live] false means the game did not answer and [reason] says why; the picture
|
||||
* stays up. Positions are never stored by the site, so there is no last-known
|
||||
* answer to fall back on here the way the presence board has one.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapLiveDto(
|
||||
val live: Boolean = false,
|
||||
val reason: String? = null,
|
||||
val mapKey: String? = null,
|
||||
val world: List<RustMapWorldDto>? = null,
|
||||
val events: List<RustMapEventDto>? = null,
|
||||
val players: List<RustMapPlayerDto>? = null,
|
||||
val playersTruncated: Boolean = false,
|
||||
val bases: List<RustMapBaseDto>? = null,
|
||||
val basesTruncated: Boolean = false,
|
||||
val mates: List<RustMapPlayerDto>? = null,
|
||||
)
|
||||
|
||||
/** A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply` or `crate`. */
|
||||
@Serializable
|
||||
data class RustMapWorldDto(
|
||||
val kind: String = "",
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
/** A locked crate being hacked: seconds left. */
|
||||
val hackLeftSec: Int? = null,
|
||||
val hacked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* What one of this site's events placed: a `zone`, `crate` or `npc` (phase 13a).
|
||||
*
|
||||
* [runId] is core's run id **as a string**, because the plugin holds it as one.
|
||||
* Core's own shapes carry it as a number, so the two are matched as text.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapEventDto(
|
||||
val kind: String = "",
|
||||
val runId: String? = null,
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
val radius: Double? = null,
|
||||
val name: String? = null,
|
||||
val prefab: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A player on the players layer, or a mate. [self] is only ever set on a mate:
|
||||
* one of the viewer's own accounts.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapPlayerDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
val sleeping: Boolean = false,
|
||||
val online: Boolean = false,
|
||||
val self: Boolean = false,
|
||||
)
|
||||
|
||||
/** A base: `tc` or `vending`. Positions only: no owner, no authorised list, no shop name. */
|
||||
@Serializable
|
||||
data class RustMapBaseDto(
|
||||
val kind: String = "",
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
)
|
||||
@@ -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<RustOnlineDto> =
|
||||
safeApiCall { api.getOnline(id) }
|
||||
|
||||
/** The map's picture, frame and layer gates for this viewer. */
|
||||
suspend fun map(id: String): ApiResult<RustMapDto> =
|
||||
safeApiCall { api.getMap(id) }
|
||||
|
||||
/** What moves on the map, as this viewer may see it. */
|
||||
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
||||
safeApiCall { api.getMapLive(id) }
|
||||
}
|
||||
|
||||
@@ -179,4 +179,16 @@ object Capability {
|
||||
|
||||
/** Core's event system (events Phase 14a). Never a module's. */
|
||||
const val EVENTS = "events"
|
||||
|
||||
/**
|
||||
* The Rust module's live map (`docs/modules/rust/PLAN.md` phase 14, D122).
|
||||
*
|
||||
* **A surface name, which [RUST]'s note warns against for a menu row** — and
|
||||
* that warning is about a row reachable on any site. This gates one tab on
|
||||
* the Rust server screen, which is reachable only where [RUST] already
|
||||
* answered, so another module declaring `map` cannot reveal it anywhere the
|
||||
* Rust module is absent. What it does answer is the one question that
|
||||
* matters here: a module older than phase 14 does not declare it.
|
||||
*/
|
||||
const val MAP = "map"
|
||||
}
|
||||
|
||||
@@ -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)) })
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Which event a run on the map belongs to (`docs/modules/rust/PLAN.md` D123, D125).
|
||||
*
|
||||
* A site-event marker carries core's run id and nothing else about its event, and
|
||||
* the app's event page is addressed by the event's slug. Core's public calendar
|
||||
* maps one to the other: each `run` entry names its run (D125) and its slug. A
|
||||
* live run is in the calendar's default window however long ago it started, so
|
||||
* **one read of the default window** holds every run a marker could name that the
|
||||
* public may see.
|
||||
*
|
||||
* **What is absent stays unlinked, and that is the gate.** Rehearsals and
|
||||
* unlisted events are not on the public calendar, so a rehearsal's zone resolves
|
||||
* to nothing and its card says only *site event*. There is no second rule here
|
||||
* for which runs to hide.
|
||||
*
|
||||
* **It re-reads at most once a [refreshMs], and only for an id it does not
|
||||
* know.** A map with one zone on it asks the calendar once, not every ten
|
||||
* seconds; a new run appearing mid-session is found within a minute. A failed
|
||||
* read keeps what the last one found and still counts as a read, so a site whose
|
||||
* calendar is down is asked once a minute rather than on every poll.
|
||||
*
|
||||
* The calendar is the same for every viewer, so nothing here is per account.
|
||||
*/
|
||||
class EventRunResolver(
|
||||
private val events: EventsRepository,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val refreshMs: Long = REFRESH_MS,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var slugs: Map<String, String> = emptyMap()
|
||||
private var readAt: Long? = null
|
||||
|
||||
/**
|
||||
* The slug for each of [runIds] the public calendar lists. Ids it does not
|
||||
* list are absent from the answer.
|
||||
*/
|
||||
suspend fun resolve(runIds: Set<String>): Map<String, String> = mutex.withLock {
|
||||
val unknown = runIds.any { it !in slugs }
|
||||
val last = readAt
|
||||
if (unknown && (last == null || now() - last >= refreshMs)) read()
|
||||
runIds.mapNotNull { id -> slugs[id]?.let { id to it } }.toMap()
|
||||
}
|
||||
|
||||
private suspend fun read() {
|
||||
readAt = now()
|
||||
val result = events.calendar()
|
||||
if (result is ApiResult.Ok) {
|
||||
// Core sends the id as a number and the plugin as a string, so they
|
||||
// meet as text.
|
||||
slugs = result.data.entries
|
||||
.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() }
|
||||
.associate { it.runId.toString() to it.slug }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REFRESH_MS = 60_000L
|
||||
}
|
||||
}
|
||||
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121).
|
||||
*
|
||||
* Ported from the web's `mapGeometry.js`, with its test cases, so the phone and
|
||||
* the page cannot place the same crate in two squares. Rust's world is centred on
|
||||
* the origin, x east and z north. The picture is the world at a scale, with an
|
||||
* ocean margin around it that is measured in **pixels** and is not scaled (the
|
||||
* rig's 3 000 m map is 3 000 × 0.5 + 2 × 500 = 2 500 px). So:
|
||||
*
|
||||
* s = (width − 2 × margin) / worldSize
|
||||
* px = (x + worldSize / 2) × s + margin
|
||||
* py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
|
||||
*
|
||||
* The web measures `py` up because Leaflet's simple frame grows north. A canvas
|
||||
* grows **down**, so [toPixel] answers `height − py`, and that flip happens here
|
||||
* and nowhere else.
|
||||
*
|
||||
* The grid is the **game's** (D119): [RustMapGeometryDto.gridCells] cells of
|
||||
* [RustMapGeometryDto.gridCellSize] metres per side, lettered from the west and
|
||||
* numbered from the north, `A0` at the north-west corner. Nothing here assumes a
|
||||
* cell size, and nothing here assumes the rig's world size either.
|
||||
*/
|
||||
class MapFrame(val geometry: RustMapGeometryDto) {
|
||||
|
||||
/** Picture pixels per world metre, or 0 for a geometry that cannot place anything. */
|
||||
val scale: Double = if (geometry.worldSize > 0 && geometry.width > 0) {
|
||||
(geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
|
||||
/** True when positions can be placed at all. */
|
||||
val canPlace: Boolean get() = scale > 0
|
||||
|
||||
val width: Double get() = geometry.width
|
||||
val height: Double get() = geometry.height
|
||||
|
||||
private val half: Double get() = geometry.worldSize / 2
|
||||
|
||||
/** A world position as a point in the picture's pixels, y measured down from the top. */
|
||||
fun toPixel(x: Double, z: Double): PicturePoint = PicturePoint(
|
||||
x = (x + half) * scale + geometry.oceanMargin,
|
||||
y = geometry.height - ((z + half) * scale + geometry.oceanMargin),
|
||||
)
|
||||
|
||||
/** A distance on the ground as picture pixels: a zone's radius. */
|
||||
fun metres(m: Double): Double = m * scale
|
||||
|
||||
/** The grid label for a world position, the way the in-game map writes it; null without a grid. */
|
||||
fun gridLabel(x: Double, z: Double): String? {
|
||||
val g = geometry
|
||||
if (g.gridCells <= 0 || g.gridCellSize <= 0) return null
|
||||
val col = floor((x + half) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
|
||||
val row = floor((half - z) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
|
||||
return "${column(col)}$row"
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid in world metres: [MapGrid.lines] as pairs of ends, and a label at
|
||||
* each cell's north-west corner.
|
||||
*/
|
||||
fun grid(): MapGrid {
|
||||
val g = geometry
|
||||
if (g.gridCells <= 0 || g.gridCellSize <= 0) return MapGrid(emptyList(), emptyList())
|
||||
val n = g.gridCells
|
||||
val c = g.gridCellSize
|
||||
val lines = buildList {
|
||||
for (i in 0..n) {
|
||||
val at = -half + i * c
|
||||
add(WorldLine(at, half, at, half - n * c))
|
||||
add(WorldLine(-half, half - i * c, -half + n * c, half - i * c))
|
||||
}
|
||||
}
|
||||
val labels = buildList {
|
||||
for (col in 0 until n) {
|
||||
for (row in 0 until n) {
|
||||
add(GridLabel("${column(col)}$row", -half + col * c, half - row * c))
|
||||
}
|
||||
}
|
||||
}
|
||||
return MapGrid(lines, labels)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
|
||||
fun column(index: Int): String {
|
||||
val name = StringBuilder()
|
||||
var n = index + 1
|
||||
while (n > 0) {
|
||||
val r = (n - 1) % 26
|
||||
name.insert(0, ('A' + r))
|
||||
n = (n - 1) / 26
|
||||
}
|
||||
return name.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A point in the picture's own pixels, y down. */
|
||||
data class PicturePoint(val x: Double, val y: Double)
|
||||
|
||||
/** A line in world metres, from (x1, z1) to (x2, z2). */
|
||||
data class WorldLine(val x1: Double, val z1: Double, val x2: Double, val z2: Double)
|
||||
|
||||
/** A grid label and the world position of its cell's north-west corner. */
|
||||
data class GridLabel(val text: String, val x: Double, val z: Double)
|
||||
|
||||
data class MapGrid(val lines: List<WorldLine>, val labels: List<GridLabel>)
|
||||
|
||||
/**
|
||||
* The picture's address, relative to the site's base.
|
||||
*
|
||||
* The module hands out `path` relative to `/api/v1`, as it does every path. It
|
||||
* is joined here as **relative** (`api/v1/…`, no leading slash), the way every
|
||||
* Retrofit path in this app is written, so a site served under a prefix keeps
|
||||
* the prefix when [com.runicgateway.app.ui.LocalAssetResolver] resolves it.
|
||||
*/
|
||||
fun mapPictureUrl(path: String): String = "api/v1/" + path.trimStart('/')
|
||||
|
||||
/** Seconds as `m:ss`, for a locked crate's hack. */
|
||||
fun countdown(seconds: Number?): String {
|
||||
val s = max(0, (seconds?.toDouble() ?: 0.0).roundToInt())
|
||||
return "${s / 60}:${(s % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the picture sits on screen: screen pixels per picture pixel, and the
|
||||
* screen position of the picture's top-left corner.
|
||||
*
|
||||
* Pure, so the gesture arithmetic is tested without Compose. **Zoom runs from
|
||||
* fit-to-screen to [MAX_ZOOM] screen pixels per picture pixel** (§31.2), and a
|
||||
* pan may take the view a quarter of the picture past its edges: some things sail
|
||||
* off the edge of the world, and the rig's cargo ship spent phase 14's probe
|
||||
* outside the picture entirely.
|
||||
*/
|
||||
data class MapTransform(val scale: Float, val offsetX: Float, val offsetY: Float) {
|
||||
|
||||
fun screenX(pictureX: Double): Float = (pictureX * scale).toFloat() + offsetX
|
||||
fun screenY(pictureY: Double): Float = (pictureY * scale).toFloat() + offsetY
|
||||
|
||||
/** The picture pixel under a screen point. */
|
||||
fun pictureX(screenX: Float): Double = ((screenX - offsetX) / scale).toDouble()
|
||||
fun pictureY(screenY: Float): Double = ((screenY - offsetY) / scale).toDouble()
|
||||
|
||||
/**
|
||||
* One step of a pinch: zoom by [zoom] about [centroidX], [centroidY] and move
|
||||
* by [panX], [panY], then clamp to what the view allows.
|
||||
*/
|
||||
fun transformed(
|
||||
view: ViewSize,
|
||||
picture: ViewSize,
|
||||
centroidX: Float,
|
||||
centroidY: Float,
|
||||
panX: Float,
|
||||
panY: Float,
|
||||
zoom: Float,
|
||||
): MapTransform {
|
||||
val next = (scale * zoom).coerceIn(minScale(view, picture), maxScale(view, picture))
|
||||
val factor = next / scale
|
||||
return MapTransform(
|
||||
scale = next,
|
||||
offsetX = centroidX - (centroidX - offsetX) * factor + panX,
|
||||
offsetY = centroidY - (centroidY - offsetY) * factor + panY,
|
||||
).clamped(view, picture)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same view in a box of a different size: the picture pixel that was at
|
||||
* the centre stays at the centre, and the zoom is clamped to what the new box
|
||||
* allows.
|
||||
*
|
||||
* **Not a refit.** The walk found the reader's zoom thrown away when the
|
||||
* status line under the map went from two lines to one: the map's box grew by
|
||||
* a line's height, and a rule that refitted on every new size could not tell
|
||||
* that from a rotation. A rotation keeps its centre too, which is what a
|
||||
* reader turning the phone to see more of the same place wants.
|
||||
*/
|
||||
fun resized(from: ViewSize, to: ViewSize, picture: ViewSize): MapTransform {
|
||||
val centreX = pictureX(from.width / 2)
|
||||
val centreY = pictureY(from.height / 2)
|
||||
val s = scale.coerceIn(minScale(to, picture), maxScale(to, picture))
|
||||
return MapTransform(
|
||||
scale = s,
|
||||
offsetX = to.width / 2 - (centreX * s).toFloat(),
|
||||
offsetY = to.height / 2 - (centreY * s).toFloat(),
|
||||
).clamped(to, picture)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the view's centre within the picture plus a quarter of it on every
|
||||
* side, so a reader can follow something off the edge and cannot lose the map.
|
||||
*/
|
||||
fun clamped(view: ViewSize, picture: ViewSize): MapTransform {
|
||||
val cx = view.width / 2
|
||||
val cy = view.height / 2
|
||||
val w = picture.width * scale
|
||||
val h = picture.height * scale
|
||||
return copy(
|
||||
offsetX = offsetX.coerceIn(cx - w * (1 + OVERSCROLL), cx + w * OVERSCROLL),
|
||||
offsetY = offsetY.coerceIn(cy - h * (1 + OVERSCROLL), cy + h * OVERSCROLL),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Screen pixels per picture pixel at the closest zoom. */
|
||||
const val MAX_ZOOM = 4f
|
||||
|
||||
/** How far past its edges the view may be taken, as a share of the picture. */
|
||||
const val OVERSCROLL = 0.25f
|
||||
|
||||
/** The whole picture, centred. */
|
||||
fun fit(view: ViewSize, picture: ViewSize): MapTransform {
|
||||
val s = minScale(view, picture)
|
||||
return MapTransform(
|
||||
scale = s,
|
||||
offsetX = (view.width - picture.width * s) / 2,
|
||||
offsetY = (view.height - picture.height * s) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
fun minScale(view: ViewSize, picture: ViewSize): Float {
|
||||
if (picture.width <= 0 || picture.height <= 0) return 1f
|
||||
return min(view.width / picture.width, view.height / picture.height)
|
||||
}
|
||||
|
||||
/** Never less than fit: a tiny picture on a large screen still fits. */
|
||||
fun maxScale(view: ViewSize, picture: ViewSize): Float = max(MAX_ZOOM, minScale(view, picture))
|
||||
}
|
||||
}
|
||||
|
||||
/** A width and height in pixels. */
|
||||
data class ViewSize(val width: Float, val height: Float)
|
||||
154
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
154
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The switches on the legend. [GRID] is the reader's own and always offered;
|
||||
* [MATES] is offered only when the server sends the viewer's own position.
|
||||
*/
|
||||
enum class MapLayer { GRID, WORLD, EVENTS, PLAYERS, BASES, MATES }
|
||||
|
||||
/**
|
||||
* One thing drawn on the map, in world metres, carrying only what its layer sent.
|
||||
*
|
||||
* One flat shape rather than one per layer so that drawing, tapping and the card
|
||||
* all read the same list, and a marker can never be tappable in a place it is not
|
||||
* drawn. Nothing here is looked up: a player's name is the one the players layer
|
||||
* carried to a viewer entitled to it (§31.4).
|
||||
*/
|
||||
data class MapMarker(
|
||||
val layer: MapLayer,
|
||||
/** `monument`, a world kind (`cargo`, `crate`…), an event kind (`zone`, `npc`…), `tc`, `vending`, `player` or `mate`. */
|
||||
val kind: String,
|
||||
val x: Double,
|
||||
val z: Double,
|
||||
/** A monument's label, a zone's name, a player's or mate's name. */
|
||||
val name: String? = null,
|
||||
/** A monument's grid square as the game wrote it; others are computed from the frame. */
|
||||
val grid: String? = null,
|
||||
/** Core's run id, for something an event placed. */
|
||||
val runId: String? = null,
|
||||
/** A zone's reach, in metres on the ground. */
|
||||
val radiusMetres: Double? = null,
|
||||
val hackLeftSec: Int? = null,
|
||||
val hacked: Boolean = false,
|
||||
val online: Boolean = false,
|
||||
val sleeping: Boolean = false,
|
||||
val self: Boolean = false,
|
||||
) {
|
||||
/** A zone is ground, not a point: it is drawn and tapped as an area. */
|
||||
val isZone: Boolean get() = layer == MapLayer.EVENTS && kind == "zone"
|
||||
}
|
||||
|
||||
/**
|
||||
* Every marker to draw, in drawing order, bottom first: monuments, world events,
|
||||
* site events, bases, players, and the viewer's own and their mates' on top.
|
||||
*
|
||||
* A layer is drawn when the server sent it **and** the reader has not switched it
|
||||
* off. A layer the server did not send is null on [live] and contributes nothing,
|
||||
* which is the whole gate; there is no second check here to disagree with it.
|
||||
*/
|
||||
fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>): List<MapMarker> = buildList {
|
||||
if (MapLayer.WORLD in shown) {
|
||||
map.monuments.orEmpty().forEach {
|
||||
add(MapMarker(MapLayer.WORLD, "monument", it.x, it.z, name = it.label, grid = it.grid))
|
||||
}
|
||||
live?.world.orEmpty().forEach {
|
||||
add(MapMarker(MapLayer.WORLD, it.kind, it.x, it.z, hackLeftSec = it.hackLeftSec, hacked = it.hacked))
|
||||
}
|
||||
}
|
||||
if (MapLayer.EVENTS in shown) {
|
||||
live?.events.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.EVENTS,
|
||||
it.kind,
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name,
|
||||
runId = it.runId?.takeIf { id -> id.isNotBlank() },
|
||||
radiusMetres = it.radius,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (MapLayer.BASES in shown) {
|
||||
live?.bases.orEmpty().forEach { add(MapMarker(MapLayer.BASES, it.kind, it.x, it.z)) }
|
||||
}
|
||||
if (MapLayer.PLAYERS in shown) {
|
||||
live?.players.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.PLAYERS,
|
||||
"player",
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name?.takeIf { n -> n.isNotBlank() } ?: it.steamId,
|
||||
online = it.online,
|
||||
sleeping = it.sleeping,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (MapLayer.MATES in shown) {
|
||||
live?.mates.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.MATES,
|
||||
"mate",
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name?.takeIf { n -> n.isNotBlank() },
|
||||
online = it.online,
|
||||
sleeping = it.sleeping,
|
||||
self = it.self,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a tap at ([tapX], [tapY]) on screen means, or null.
|
||||
*
|
||||
* **A point wins over a zone.** The nearest point within [reachPx] is the answer;
|
||||
* only when there is none does a zone answer, and then one whose ground the tap
|
||||
* is on or within reach of. Otherwise a zone drawn round a monument would swallow
|
||||
* every tap on the monument, and on the players standing in it. Among equals the
|
||||
* one drawn last, which is the one on top, wins.
|
||||
*/
|
||||
fun nearestMarker(
|
||||
markers: List<MapMarker>,
|
||||
frame: MapFrame,
|
||||
transform: MapTransform,
|
||||
tapX: Float,
|
||||
tapY: Float,
|
||||
reachPx: Float,
|
||||
): MapMarker? {
|
||||
fun distance(m: MapMarker): Double {
|
||||
val p = frame.toPixel(m.x, m.z)
|
||||
val d = hypot(transform.screenX(p.x) - tapX.toDouble(), transform.screenY(p.y) - tapY.toDouble())
|
||||
if (!m.isZone) return d
|
||||
val radius = frame.metres(m.radiusMetres ?: 0.0) * transform.scale
|
||||
return max(0.0, d - radius)
|
||||
}
|
||||
|
||||
fun closest(candidates: List<MapMarker>): MapMarker? = candidates
|
||||
.asReversed()
|
||||
.map { it to distance(it) }
|
||||
.filter { it.second <= reachPx }
|
||||
.minByOrNull { it.second }
|
||||
?.first
|
||||
|
||||
return closest(markers.filterNot { it.isZone }) ?: closest(markers.filter { it.isZone })
|
||||
}
|
||||
|
||||
/** The distinct run ids the events layer names, for [EventRunResolver]. */
|
||||
fun eventRunIds(live: RustMapLiveDto?): Set<String> =
|
||||
live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet()
|
||||
680
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
680
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
@@ -0,0 +1,680 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Size
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLayerDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.ui.LocalAssetResolver
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The Map tab (`docs/modules/rust/PLAN.md` §31, D121–D124).
|
||||
*
|
||||
* **The phone draws the map itself** (D121): Coil fetches the picture once per
|
||||
* map into its disk cache, since the URL carries the hash, and a [Canvas] draws
|
||||
* the picture, the grid and the markers, with pinch, pan and double tap.
|
||||
*
|
||||
* **Nothing here decides who may see what.** The server sends only the layers
|
||||
* this viewer may see. The switches on the legend are the reader's convenience
|
||||
* and never a boundary, and a layer the viewer was not sent is still listed,
|
||||
* disabled, with who can see it: "staff only" explains an empty map where
|
||||
* silence would imply an empty server (§23.3's shape).
|
||||
*/
|
||||
@Composable
|
||||
fun RustMapPanel(
|
||||
serverOnline: Boolean,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
viewModel: RustMapViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = ui.map) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> {
|
||||
val map = s.data
|
||||
val geometry = map.geometry
|
||||
if (geometry == null) {
|
||||
EmptyView(stringResource(R.string.rust_map_none))
|
||||
return
|
||||
}
|
||||
|
||||
// D124: while RESUMED and on this tab, which is exactly while this
|
||||
// composable is on screen. Nothing is asked for a viewer who is sent
|
||||
// nothing that moves.
|
||||
if (map.anyLive) {
|
||||
val interval = map.pollMs?.takeIf { it > 0 } ?: DEFAULT_POLL_MS
|
||||
key(interval) { PollWhileResumed(intervalMs = interval) { viewModel.poll() } }
|
||||
}
|
||||
|
||||
MapContent(
|
||||
map = map,
|
||||
frame = remember(geometry) { MapFrame(geometry) },
|
||||
ui = ui,
|
||||
serverOnline = serverOnline,
|
||||
onToggle = viewModel::toggle,
|
||||
onSelect = viewModel::select,
|
||||
onOpenEvent = onOpenEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_POLL_MS = 10_000L
|
||||
|
||||
/** What became of the picture. */
|
||||
private sealed interface Picture {
|
||||
data object None : Picture
|
||||
data object Loading : Picture
|
||||
data object Failed : Picture
|
||||
data class Ready(val bitmap: ImageBitmap) : Picture
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapContent(
|
||||
map: RustMapDto,
|
||||
frame: MapFrame,
|
||||
ui: RustMapUi,
|
||||
serverOnline: Boolean,
|
||||
onToggle: (MapLayer) -> Unit,
|
||||
onSelect: (MapMarker?) -> Unit,
|
||||
onOpenEvent: (String, String) -> Unit,
|
||||
) {
|
||||
val picture = rememberPicture(map.picture?.path)
|
||||
val markers = remember(map, ui.live, ui.shown) { mapMarkers(map, ui.live, ui.shown) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when (picture) {
|
||||
Picture.None -> Note(stringResource(R.string.rust_map_no_picture))
|
||||
Picture.Failed -> Note(stringResource(R.string.rust_map_picture_failed))
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
MapCanvas(
|
||||
frame = frame,
|
||||
background = parseColour(frame.geometry.background) ?: OCEAN,
|
||||
picture = (picture as? Picture.Ready)?.bitmap,
|
||||
showGrid = MapLayer.GRID in ui.shown,
|
||||
markers = markers,
|
||||
selected = ui.selected,
|
||||
// A new map is a new frame; the reader's zoom on the old one means nothing.
|
||||
resetKey = map.mapKey,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
ui.selected?.let { marker ->
|
||||
MarkerCard(
|
||||
marker = marker,
|
||||
grid = marker.grid ?: frame.gridLabel(marker.x, marker.z),
|
||||
eventSlug = marker.runId?.let { ui.eventSlugs[it] },
|
||||
onOpenEvent = onOpenEvent,
|
||||
onClose = { onSelect(null) },
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Note(liveStatus(map, ui, serverOnline))
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = LEGEND_MAX_HEIGHT)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Legend(map, ui.live, ui.shown, onToggle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LEGEND_MAX_HEIGHT = 240.dp
|
||||
|
||||
/**
|
||||
* The picture, decoded once at its own size as `RGB_565` with hardware bitmaps
|
||||
* off: half the memory of ARGB, 12.5 MB at the rig's 2 500 px (§31.2).
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberPicture(path: String?): Picture {
|
||||
val context = LocalContext.current
|
||||
val resolve = LocalAssetResolver.current
|
||||
val url = path?.takeIf { it.isNotBlank() }?.let { resolve(mapPictureUrl(it)) }
|
||||
|
||||
// Keyed on the URL, which carries the picture's hash: a new map is a new
|
||||
// load, and the old picture stays drawn until the new one has arrived.
|
||||
var picture by remember { mutableStateOf(if (url == null) Picture.None else Picture.Loading) }
|
||||
LaunchedEffect(url) {
|
||||
picture = if (url == null) Picture.None else loadPicture(context, url)
|
||||
}
|
||||
return picture
|
||||
}
|
||||
|
||||
private suspend fun loadPicture(context: Context, url: String): Picture {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.size(Size.ORIGINAL)
|
||||
.bitmapConfig(Bitmap.Config.RGB_565)
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
val result = context.imageLoader.execute(request) as? SuccessResult
|
||||
val bitmap = (result?.drawable as? BitmapDrawable)?.bitmap ?: return Picture.Failed
|
||||
return Picture.Ready(bitmap.asImageBitmap())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapCanvas(
|
||||
frame: MapFrame,
|
||||
background: Color,
|
||||
picture: ImageBitmap?,
|
||||
showGrid: Boolean,
|
||||
markers: List<MapMarker>,
|
||||
selected: MapMarker?,
|
||||
resetKey: String?,
|
||||
onSelect: (MapMarker?) -> Unit,
|
||||
) {
|
||||
val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat())
|
||||
var view by remember { mutableStateOf<ViewSize?>(null) }
|
||||
// Null until the reader moves the map: until then, and after a new map
|
||||
// resets it, the map is drawn fitted to the view. **Saveable**, because
|
||||
// Open event leaves this screen and Back returns to it: the walk found the
|
||||
// reader's zoom gone on the way back, the card still up over a whole-world
|
||||
// view of the marker it described.
|
||||
var moved by rememberSaveable(resetKey, pictureSize, stateSaver = TransformSaver) {
|
||||
mutableStateOf<MapTransform?>(null)
|
||||
}
|
||||
val transform = moved ?: view?.let { MapTransform.fit(it, pictureSize) }
|
||||
|
||||
// Read through updated state so the gesture handlers, which are installed
|
||||
// once, act on what is drawn now rather than on what was drawn when they were.
|
||||
val currentMarkers by rememberUpdatedState(markers)
|
||||
val currentTransform by rememberUpdatedState(transform)
|
||||
val currentFrame by rememberUpdatedState(frame)
|
||||
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val labelStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = GRID_LABEL)
|
||||
val grid = remember(frame) { frame.grid() }
|
||||
val labels: List<Pair<GridLabel, TextLayoutResult>> = remember(grid, textMeasurer) {
|
||||
grid.labels.map { it to textMeasurer.measure(it.text, labelStyle) }
|
||||
}
|
||||
val description = stringResource(R.string.rust_map_description)
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.background(SURROUND)
|
||||
.semantics { contentDescription = description }
|
||||
.onSizeChanged {
|
||||
val next = ViewSize(it.width.toFloat(), it.height.toFloat())
|
||||
// A new size keeps the reader's view rather than refitting: the
|
||||
// box grows and shrinks with the status line under it, and a
|
||||
// rotation is still the same place. The first size after a
|
||||
// return is not a new one, and keeps the restored zoom.
|
||||
val previous = view
|
||||
if (next != previous) {
|
||||
val m = moved
|
||||
if (previous != null && m != null) moved = m.resized(previous, next, pictureSize)
|
||||
view = next
|
||||
}
|
||||
}
|
||||
.pointerInput(pictureSize) {
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
val v = view ?: return@detectTransformGestures
|
||||
val t = currentTransform ?: return@detectTransformGestures
|
||||
moved = t.transformed(v, pictureSize, centroid.x, centroid.y, pan.x, pan.y, zoom)
|
||||
}
|
||||
}
|
||||
.pointerInput(pictureSize) {
|
||||
detectTapGestures(
|
||||
onTap = { at ->
|
||||
val t = currentTransform ?: return@detectTapGestures
|
||||
onSelect(nearestMarker(currentMarkers, currentFrame, t, at.x, at.y, TAP_REACH.toPx()))
|
||||
},
|
||||
onDoubleTap = { at ->
|
||||
val v = view ?: return@detectTapGestures
|
||||
val t = currentTransform ?: return@detectTapGestures
|
||||
moved = t.transformed(v, pictureSize, at.x, at.y, 0f, 0f, 2f)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val t = transform ?: return@Canvas
|
||||
val left = t.offsetX
|
||||
val top = t.offsetY
|
||||
val w = pictureSize.width * t.scale
|
||||
val h = pictureSize.height * t.scale
|
||||
|
||||
// Without a picture the geometry is filled with the game's own colour.
|
||||
drawRect(background, topLeft = Offset(left, top), size = androidx.compose.ui.geometry.Size(w, h))
|
||||
picture?.let {
|
||||
drawImage(
|
||||
image = it,
|
||||
dstOffset = IntOffset(left.roundToInt(), top.roundToInt()),
|
||||
dstSize = IntSize(w.roundToInt(), h.roundToInt()),
|
||||
filterQuality = FilterQuality.Low,
|
||||
)
|
||||
}
|
||||
|
||||
if (showGrid) drawGrid(frame, t, grid, labels)
|
||||
markers.forEach { drawMarker(frame, t, it, highlighted = it == selected) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGrid(
|
||||
frame: MapFrame,
|
||||
t: MapTransform,
|
||||
grid: MapGrid,
|
||||
labels: List<Pair<GridLabel, TextLayoutResult>>,
|
||||
) {
|
||||
val stroke = 1.dp.toPx()
|
||||
fun at(x: Double, z: Double): Offset {
|
||||
val p = frame.toPixel(x, z)
|
||||
return Offset(t.screenX(p.x), t.screenY(p.y))
|
||||
}
|
||||
grid.lines.forEach { drawLine(GRID_LINE, at(it.x1, it.z1), at(it.x2, it.z2), strokeWidth = stroke) }
|
||||
|
||||
// Labels only once a cell is wide enough on screen to hold one: at the
|
||||
// fitted zoom a 20-cell map's labels overlap into a wall of text (found on
|
||||
// the phase 14 walk, and the web's rule).
|
||||
val cellPx = frame.metres(frame.geometry.gridCellSize) * t.scale
|
||||
if (cellPx < LABEL_MIN_CELL.toPx()) return
|
||||
val pad = Offset(3.dp.toPx(), 2.dp.toPx())
|
||||
labels.forEach { (label, layout) ->
|
||||
val corner = at(label.x, label.z)
|
||||
// Only the ones on screen: a zoomed-in 20-cell map has 400 and shows a few.
|
||||
if (corner.x > size.width || corner.y > size.height) return@forEach
|
||||
if (corner.x + cellPx < 0 || corner.y + cellPx < 0) return@forEach
|
||||
drawText(layout, topLeft = corner + pad)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One marker. **Points are drawn at a fixed size on screen** whatever the zoom, so
|
||||
* a dot does not grow over the monument it sits on at 4×; **a zone is drawn on
|
||||
* the ground**, in metres, because how far it reaches is what it says (§31.4).
|
||||
*/
|
||||
private fun DrawScope.drawMarker(frame: MapFrame, t: MapTransform, m: MapMarker, highlighted: Boolean) {
|
||||
val p = frame.toPixel(m.x, m.z)
|
||||
val centre = Offset(t.screenX(p.x), t.screenY(p.y))
|
||||
|
||||
if (m.isZone) {
|
||||
val radius = (frame.metres(m.radiusMetres ?: 0.0) * t.scale).toFloat()
|
||||
drawCircle(EVENT.copy(alpha = 0.12f), radius, centre)
|
||||
drawCircle(EVENT, radius, centre, style = Stroke(width = (if (highlighted) 3 else 2).dp.toPx()))
|
||||
return
|
||||
}
|
||||
|
||||
val style = markerStyle(m)
|
||||
val radius = style.radius.dp.toPx()
|
||||
drawCircle(style.colour.copy(alpha = 0.95f), radius, centre)
|
||||
drawCircle(Color.Black, radius, centre, style = Stroke(width = style.outline.dp.toPx()))
|
||||
if (highlighted) drawCircle(Color.White, radius + 4.dp.toPx(), centre, style = Stroke(width = 2.dp.toPx()))
|
||||
}
|
||||
|
||||
/** The zoom and pan as three floats, so they survive leaving the screen. */
|
||||
private val TransformSaver = Saver<MapTransform?, FloatArray>(
|
||||
save = { t -> t?.let { floatArrayOf(it.scale, it.offsetX, it.offsetY) } },
|
||||
restore = { MapTransform(it[0], it[1], it[2]) },
|
||||
)
|
||||
|
||||
private data class MarkerStyle(val colour: Color, val radius: Int, val outline: Int = 1)
|
||||
|
||||
/** The web's colours and sizes (`MapView.jsx`), so the two maps read alike. */
|
||||
private fun markerStyle(m: MapMarker): MarkerStyle = when (m.layer) {
|
||||
MapLayer.WORLD -> when (m.kind) {
|
||||
"monument" -> MarkerStyle(MONUMENT, 4)
|
||||
"cargo" -> MarkerStyle(WORLD_COLOURS.getValue("cargo"), 7)
|
||||
else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5)
|
||||
}
|
||||
MapLayer.EVENTS -> MarkerStyle(EVENT, 5)
|
||||
MapLayer.PLAYERS -> if (m.online) MarkerStyle(ONLINE, 5) else MarkerStyle(SLEEPING, 4)
|
||||
MapLayer.BASES -> MarkerStyle(if (m.kind == "vending") VENDING else TC, 4)
|
||||
MapLayer.MATES -> if (m.self) MarkerStyle(SELF, 8, 2) else MarkerStyle(MATE, 6, 2)
|
||||
MapLayer.GRID -> MarkerStyle(Color.White, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* What was tapped: what it is, its grid square, and what its layer carried.
|
||||
* Nothing is looked up beyond what was sent (§31.4).
|
||||
*/
|
||||
@Composable
|
||||
private fun MarkerCard(
|
||||
marker: MapMarker,
|
||||
grid: String?,
|
||||
eventSlug: String?,
|
||||
onOpenEvent: (String, String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ShardCard(modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Text(markerTitle(marker), style = MaterialTheme.typography.titleSmall)
|
||||
markerDetail(marker)?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
grid?.let {
|
||||
Text(
|
||||
stringResource(R.string.rust_map_grid, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
// D123: a run core's public calendar lists opens the app's own event
|
||||
// page on that run. A run it does not list (a rehearsal, an
|
||||
// unlisted event) says only what it is.
|
||||
val runId = marker.runId
|
||||
if (marker.layer == MapLayer.EVENTS && runId != null && eventSlug != null) {
|
||||
TextButton(onClick = { onOpenEvent(eventSlug, runId) }) {
|
||||
Text(stringResource(R.string.rust_map_open_event))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onClose) { Text(stringResource(R.string.rust_map_close)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun markerTitle(m: MapMarker): String = when (m.layer) {
|
||||
MapLayer.WORLD -> when (m.kind) {
|
||||
"monument" -> m.name ?: m.kind
|
||||
"cargo" -> stringResource(R.string.rust_map_cargo)
|
||||
"heli" -> stringResource(R.string.rust_map_heli)
|
||||
"chinook" -> stringResource(R.string.rust_map_chinook)
|
||||
"bradley" -> stringResource(R.string.rust_map_bradley)
|
||||
"supply" -> stringResource(R.string.rust_map_supply)
|
||||
"crate" -> stringResource(R.string.rust_map_crate)
|
||||
else -> prefabName(m.kind)
|
||||
}
|
||||
MapLayer.EVENTS -> when (m.kind) {
|
||||
"zone" -> m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_event_zone)
|
||||
"npc" -> stringResource(R.string.rust_map_event_npc)
|
||||
else -> stringResource(R.string.rust_map_event_crate)
|
||||
}
|
||||
MapLayer.PLAYERS -> m.name ?: ""
|
||||
MapLayer.BASES -> stringResource(if (m.kind == "vending") R.string.rust_map_vending else R.string.rust_map_tc)
|
||||
MapLayer.MATES -> if (m.self) stringResource(R.string.rust_map_you) else m.name ?: stringResource(R.string.rust_map_clan_mate)
|
||||
MapLayer.GRID -> ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun markerDetail(m: MapMarker): String? = when (m.layer) {
|
||||
MapLayer.WORLD -> when {
|
||||
m.kind != "crate" -> null
|
||||
m.hacked -> stringResource(R.string.rust_map_crate_hacked)
|
||||
m.hackLeftSec != null -> stringResource(R.string.rust_map_crate_hack, countdown(m.hackLeftSec))
|
||||
else -> null
|
||||
}
|
||||
// Every site-event marker says so, linked or not (§31.2): a run the public
|
||||
// calendar does not list gets this line and no button.
|
||||
MapLayer.EVENTS -> stringResource(R.string.rust_map_site_event)
|
||||
MapLayer.PLAYERS, MapLayer.MATES -> when {
|
||||
!m.online -> stringResource(R.string.rust_map_player_offline)
|
||||
m.sleeping -> stringResource(R.string.rust_map_player_sleeping)
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* One row for each layer. A layer the viewer was sent has a switch; one they
|
||||
* were not is listed disabled with who can see it, and the players layer says
|
||||
* when it is narrower than its own switch because presence is (D113).
|
||||
*/
|
||||
@Composable
|
||||
private fun Legend(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>, onToggle: (MapLayer) -> Unit) {
|
||||
LegendRow(stringResource(R.string.rust_map_layer_grid), emptyList(), true, MapLayer.GRID in shown, null) {
|
||||
onToggle(MapLayer.GRID)
|
||||
}
|
||||
LAYER_ROWS.forEach { (layer, labelRes) ->
|
||||
val gate = map.layers.of(layer)
|
||||
var note = if (gate.visible) null else hiddenNote(gate)
|
||||
if (layer == MapLayer.PLAYERS && gate.cappedByPresence && !gate.visible) {
|
||||
note = stringResource(R.string.rust_map_capped, note.orEmpty())
|
||||
}
|
||||
if (gate.visible && layer == MapLayer.PLAYERS && live?.playersTruncated == true) {
|
||||
note = stringResource(R.string.rust_map_players_truncated)
|
||||
}
|
||||
if (gate.visible && layer == MapLayer.BASES && live?.basesTruncated == true) {
|
||||
note = stringResource(R.string.rust_map_bases_truncated)
|
||||
}
|
||||
LegendRow(stringResource(labelRes), SWATCHES.getValue(layer), gate.visible, gate.visible && layer in shown, note) {
|
||||
onToggle(layer)
|
||||
}
|
||||
}
|
||||
val mates = map.mates
|
||||
if (mates.visible) {
|
||||
LegendRow(
|
||||
stringResource(R.string.rust_map_layer_mates),
|
||||
SWATCHES.getValue(MapLayer.MATES),
|
||||
true,
|
||||
MapLayer.MATES in shown,
|
||||
stringResource(R.string.rust_map_mates_note),
|
||||
) { onToggle(MapLayer.MATES) }
|
||||
} else if (mates.on && mates.signedIn && !mates.linked) {
|
||||
// Offered only to a signed-in, unlinked viewer on a server with the switch
|
||||
// on, as the web does: anyone else could not act on it.
|
||||
LegendRow(
|
||||
stringResource(R.string.rust_map_layer_mates),
|
||||
SWATCHES.getValue(MapLayer.MATES),
|
||||
false,
|
||||
false,
|
||||
stringResource(R.string.rust_map_mates_link),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun hiddenNote(gate: RustMapLayerDto): String = stringResource(
|
||||
when (gate.audience) {
|
||||
"signed_in" -> R.string.rust_map_hidden_signin
|
||||
"public" -> R.string.rust_map_hidden_public
|
||||
else -> R.string.rust_map_hidden_staff
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LegendRow(
|
||||
label: String,
|
||||
swatches: List<Color>,
|
||||
enabled: Boolean,
|
||||
checked: Boolean,
|
||||
note: String?,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(checked = checked, onCheckedChange = { onToggle() }, enabled = enabled)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
swatches.forEach {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Box(Modifier.size(10.dp).background(it, CircleShape))
|
||||
}
|
||||
}
|
||||
note?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Note(text: String) {
|
||||
if (text.isBlank()) return
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** The line under the map, in the web's words (`liveStatus` in `MapView.jsx`). */
|
||||
@Composable
|
||||
private fun liveStatus(map: RustMapDto, ui: RustMapUi, serverOnline: Boolean): String {
|
||||
if (!map.anyLive) return stringResource(R.string.rust_map_status_nothing_live)
|
||||
val live = ui.live
|
||||
if (live == null) {
|
||||
return stringResource(if (ui.liveFailed) R.string.rust_map_status_failed else R.string.rust_map_status_asking)
|
||||
}
|
||||
if (ui.liveFailed) return stringResource(R.string.rust_map_status_failed_kept)
|
||||
if (!live.live) {
|
||||
return stringResource(if (serverOnline) R.string.rust_map_status_not_now else R.string.rust_map_status_offline)
|
||||
}
|
||||
val at = ui.liveAt ?: return ""
|
||||
return stringResource(R.string.rust_map_status_as_of, CLOCK.format(Instant.ofEpochMilli(at)))
|
||||
}
|
||||
|
||||
private val CLOCK: DateTimeFormatter =
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
|
||||
|
||||
private fun com.runicgateway.app.data.api.dto.RustMapLayersDto.of(layer: MapLayer): RustMapLayerDto = when (layer) {
|
||||
MapLayer.WORLD -> world
|
||||
MapLayer.EVENTS -> events
|
||||
MapLayer.PLAYERS -> players
|
||||
MapLayer.BASES -> bases
|
||||
else -> RustMapLayerDto()
|
||||
}
|
||||
|
||||
/** `#RRGGBB` as the plugin sends it, or null for anything else. */
|
||||
internal fun parseColour(value: String?): Color? {
|
||||
val hex = value?.trim()?.removePrefix("#") ?: return null
|
||||
if (hex.length != 6) return null
|
||||
val rgb = hex.toLongOrNull(16) ?: return null
|
||||
return Color(0xFF000000 or rgb)
|
||||
}
|
||||
|
||||
private val TAP_REACH = 24.dp
|
||||
private val LABEL_MIN_CELL = 30.dp
|
||||
|
||||
// The web's palette (`MapView.jsx` COLOURS), so a reader moving between the two
|
||||
// reads the same marker as the same thing.
|
||||
private val OCEAN = Color(0xFF0B3B4A)
|
||||
private val SURROUND = Color(0xFF071F27)
|
||||
private val GRID_LINE = Color.White.copy(alpha = 0.18f)
|
||||
private val GRID_LABEL = Color.White.copy(alpha = 0.55f)
|
||||
private val MONUMENT = Color(0xFFE8D9A8)
|
||||
private val EVENT = Color(0xFFCE93D8)
|
||||
private val ONLINE = Color(0xFFFFFFFF)
|
||||
private val SLEEPING = Color(0xFF9E9E9E)
|
||||
private val TC = Color(0xFFFF7043)
|
||||
private val VENDING = Color(0xFF26A69A)
|
||||
private val SELF = Color(0xFF00E5FF)
|
||||
private val MATE = Color(0xFF7CFFB2)
|
||||
private val WORLD_COLOURS = mapOf(
|
||||
"cargo" to Color(0xFF4FC3F7),
|
||||
"heli" to Color(0xFFEF5350),
|
||||
"chinook" to Color(0xFFFFA726),
|
||||
"bradley" to Color(0xFFA1887F),
|
||||
"supply" to Color(0xFF66BB6A),
|
||||
"crate" to Color(0xFFFFEE58),
|
||||
)
|
||||
|
||||
private val LAYER_ROWS = listOf(
|
||||
MapLayer.WORLD to R.string.rust_map_layer_world,
|
||||
MapLayer.EVENTS to R.string.rust_map_layer_events,
|
||||
MapLayer.PLAYERS to R.string.rust_map_layer_players,
|
||||
MapLayer.BASES to R.string.rust_map_layer_bases,
|
||||
)
|
||||
|
||||
private val SWATCHES = mapOf(
|
||||
MapLayer.WORLD to listOf(MONUMENT, WORLD_COLOURS.getValue("cargo"), WORLD_COLOURS.getValue("heli"), WORLD_COLOURS.getValue("crate")),
|
||||
MapLayer.EVENTS to listOf(EVENT),
|
||||
MapLayer.PLAYERS to listOf(ONLINE, SLEEPING),
|
||||
MapLayer.BASES to listOf(TC, VENDING),
|
||||
MapLayer.MATES to listOf(SELF, MATE),
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Everything the Map tab is showing. */
|
||||
data class RustMapUi(
|
||||
val map: UiState<RustMapDto> = UiState.Loading,
|
||||
/** The last live answer, or null before the first. Kept when a later ask fails. */
|
||||
val live: RustMapLiveDto? = null,
|
||||
/** True when the most recent ask failed; [live] is then the one before it. */
|
||||
val liveFailed: Boolean = false,
|
||||
/** When [live] arrived, epoch ms. */
|
||||
val liveAt: Long? = null,
|
||||
/** The legend's switches: the reader's convenience, never a boundary. */
|
||||
val shown: Set<MapLayer> = MapLayer.entries.toSet(),
|
||||
val selected: MapMarker? = null,
|
||||
/** Run id → event slug, for the runs core's public calendar lists. */
|
||||
val eventSlugs: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One server's map (`docs/modules/rust/PLAN.md` §31, D121–D125; PLAN.md M17).
|
||||
*
|
||||
* ## It draws what it is sent
|
||||
*
|
||||
* `map` and `map/live` are projected per viewer **on the server**, from the same
|
||||
* bearer token every other request carries. A layer this viewer may not see is
|
||||
* absent from the answer, so nothing here decides who sees what.
|
||||
*
|
||||
* ## Keyed on the signed-in account
|
||||
*
|
||||
* Mates and the players layer are per viewer, and this view model lives as long
|
||||
* as the server screen's back-stack entry, across a sign-out and a sign-in as
|
||||
* somebody else. Events phase 14b found what that costs: a view model that loads
|
||||
* once showed the next account the previous one's answer without asking. So the
|
||||
* account is what this keys on. A change of account drops everything drawn,
|
||||
* including the tapped card, and asks again, and an answer to a question asked
|
||||
* for the previous account is thrown away when it lands (the [generation]).
|
||||
* Signing out is a change too: the map is public, so a signed-out reader gets
|
||||
* the public map rather than an empty one.
|
||||
*
|
||||
* ## When it asks
|
||||
*
|
||||
* `map` once when the tab opens, and again when a live answer names a different
|
||||
* `mapKey`: a wipe or a new seed, whose picture follows without leaving the tab.
|
||||
* `map/live` on the screen's poll (D124), and not at all when the server sends
|
||||
* this viewer no moving layer.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustMapViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
events: EventsRepository,
|
||||
sessionManager: SessionManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||
|
||||
private val resolver = EventRunResolver(events)
|
||||
|
||||
private val _state = MutableStateFlow(RustMapUi())
|
||||
val state: StateFlow<RustMapUi> = _state.asStateFlow()
|
||||
|
||||
/** Bumped on every change of account; an answer from an older one is dropped. */
|
||||
private var generation = 0
|
||||
|
||||
/** A poll still waiting for its answer: the next tick does not stack another. */
|
||||
private var polling = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionManager.state
|
||||
.map { (it as? Session.SignedIn)?.user?.id }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
generation++
|
||||
polling = false
|
||||
_state.update { s -> RustMapUi(shown = s.shown) }
|
||||
load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The map's picture, frame and gates. A retry after a failure too. */
|
||||
fun load() {
|
||||
val asked = generation
|
||||
_state.update { it.copy(map = UiState.Loading) }
|
||||
viewModelScope.launch { askMap(asked) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll tick. Asks only once the map has a frame to place things in and
|
||||
* only when this viewer is sent something that moves.
|
||||
*/
|
||||
fun poll() {
|
||||
val map = (_state.value.map as? UiState.Success)?.data ?: return
|
||||
if (map.geometry == null || !map.anyLive || polling) return
|
||||
|
||||
val asked = generation
|
||||
polling = true
|
||||
viewModelScope.launch {
|
||||
val result = repository.mapLive(serverId)
|
||||
if (asked != generation) return@launch
|
||||
polling = false
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Ok -> {
|
||||
val answer = result.data
|
||||
_state.update {
|
||||
it.copy(live = answer, liveFailed = false, liveAt = System.currentTimeMillis())
|
||||
}
|
||||
// A new map under the same server: the picture follows.
|
||||
if (answer.mapKey != null && map.mapKey != null && answer.mapKey != map.mapKey) {
|
||||
askMap(asked)
|
||||
}
|
||||
resolveRuns(answer, asked)
|
||||
}
|
||||
// Positions are kept on a failure, in Polling.kt's shape: the last
|
||||
// answer stays drawn and the status line says the ask failed.
|
||||
else -> _state.update { it.copy(liveFailed = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle(layer: MapLayer) {
|
||||
_state.update {
|
||||
val shown = if (layer in it.shown) it.shown - layer else it.shown + layer
|
||||
// A card for something the reader just hid would describe a marker
|
||||
// that is no longer on the map.
|
||||
val selected = it.selected?.takeIf { m -> m.layer in shown }
|
||||
it.copy(shown = shown, selected = selected)
|
||||
}
|
||||
}
|
||||
|
||||
fun select(marker: MapMarker?) {
|
||||
_state.update { it.copy(selected = marker) }
|
||||
}
|
||||
|
||||
private suspend fun askMap(asked: Int) {
|
||||
val result = repository.map(serverId)
|
||||
if (asked != generation) return
|
||||
_state.update { current ->
|
||||
// A failed RE-read after a new mapKey keeps the map that is drawn:
|
||||
// the old picture under new positions is better than an error screen,
|
||||
// and the next poll asks again.
|
||||
if (result !is ApiResult.Ok && current.map is UiState.Success) current
|
||||
else current.copy(map = result.toUiState())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveRuns(answer: RustMapLiveDto, asked: Int) {
|
||||
val ids = eventRunIds(answer)
|
||||
if (ids.isEmpty()) return
|
||||
val slugs = resolver.resolve(ids)
|
||||
if (asked != generation) return
|
||||
_state.update { it.copy(eventSlugs = slugs) }
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the server sends this viewer at least one layer that moves. */
|
||||
val RustMapDto.anyLive: Boolean
|
||||
get() = layers.world.visible || layers.events.visible || layers.players.visible ||
|
||||
layers.bases.visible || mates.visible
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RustOnlineDto> = Polled(),
|
||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
||||
)
|
||||
/** Whether the Map tab is offered, under the app's one capability rule (D122). */
|
||||
val mapAvailable: Boolean = true,
|
||||
) {
|
||||
/** The tabs this site offers, in order. */
|
||||
val tabs: List<RustTab> get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable }
|
||||
}
|
||||
|
||||
/**
|
||||
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +586,55 @@
|
||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||
<string name="rust_tab_online">Online</string>
|
||||
<string name="rust_tab_wipes">Wipes</string>
|
||||
<string name="rust_tab_map">Map</string>
|
||||
<!-- The Map tab (Rust phase 15, D121–D125). The wording follows the web's Map
|
||||
tab, so a reader moving between the two reads the same sentences. -->
|
||||
<string name="rust_map_none">No map yet. This server has not told the site which map it is on. The map will appear once the server is up.</string>
|
||||
<string name="rust_map_no_picture">This server has no picture of its map, so the layers are drawn on a plain background.</string>
|
||||
<string name="rust_map_picture_failed">The picture of the map could not be loaded, so the layers are drawn on a plain background.</string>
|
||||
<string name="rust_map_description">Map of the server</string>
|
||||
<string name="rust_map_status_nothing_live">No moving layers are shown to you on this server.</string>
|
||||
<string name="rust_map_status_asking">Asking the server where things are…</string>
|
||||
<string name="rust_map_status_not_now">The server did not say where things are just now. The site asks again every ten seconds.</string>
|
||||
<string name="rust_map_status_offline">The server is offline, so nothing is moving on its map.</string>
|
||||
<string name="rust_map_status_failed">Positions could not be loaded.</string>
|
||||
<string name="rust_map_status_failed_kept">Could not refresh just now. These are the last positions the site heard.</string>
|
||||
<string name="rust_map_status_as_of">Positions as of %1$s. They refresh every ten seconds while this tab is open.</string>
|
||||
<string name="rust_map_layer_grid">Grid</string>
|
||||
<string name="rust_map_layer_world">Monuments & world events</string>
|
||||
<string name="rust_map_layer_events">Site events</string>
|
||||
<string name="rust_map_layer_players">Players</string>
|
||||
<string name="rust_map_layer_bases">Bases</string>
|
||||
<string name="rust_map_layer_mates">You and your clan</string>
|
||||
<string name="rust_map_hidden_signin">Sign in to see this layer.</string>
|
||||
<string name="rust_map_hidden_public">Shown to everyone.</string>
|
||||
<string name="rust_map_hidden_staff">Shown to staff only.</string>
|
||||
<string name="rust_map_capped">%1$s Limited by who may see who is online.</string>
|
||||
<string name="rust_map_players_truncated">Not every sleeper is shown.</string>
|
||||
<string name="rust_map_bases_truncated">Not every base is shown.</string>
|
||||
<string name="rust_map_mates_note">Your own position, and clan mates who are online.</string>
|
||||
<string name="rust_map_mates_link">Link your Steam account to see yourself and your clan here.</string>
|
||||
<string name="rust_map_cargo">Cargo ship</string>
|
||||
<string name="rust_map_heli">Patrol helicopter</string>
|
||||
<string name="rust_map_chinook">Chinook</string>
|
||||
<string name="rust_map_bradley">Bradley APC</string>
|
||||
<string name="rust_map_supply">Supply drop</string>
|
||||
<string name="rust_map_crate">Locked crate</string>
|
||||
<string name="rust_map_crate_hack">%1$s left on the hack</string>
|
||||
<string name="rust_map_crate_hacked">Hacked</string>
|
||||
<string name="rust_map_event_zone">Event zone</string>
|
||||
<string name="rust_map_event_crate">Event crate</string>
|
||||
<string name="rust_map_event_npc">Event NPC</string>
|
||||
<string name="rust_map_site_event">Site event</string>
|
||||
<string name="rust_map_open_event">Open event</string>
|
||||
<string name="rust_map_tc">Tool cupboard</string>
|
||||
<string name="rust_map_vending">Vending machine</string>
|
||||
<string name="rust_map_player_sleeping">Sleeping</string>
|
||||
<string name="rust_map_player_offline">Asleep, offline</string>
|
||||
<string name="rust_map_you">You</string>
|
||||
<string name="rust_map_clan_mate">Clan mate</string>
|
||||
<string name="rust_map_grid">Grid %1$s</string>
|
||||
<string name="rust_map_close">Close</string>
|
||||
<string name="rust_all_time">All time</string>
|
||||
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||
<string name="rust_wipe_this_one">Current</string>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,8 @@ package com.runicgateway.app.data.api.fake
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
@@ -31,12 +33,23 @@ class FakeRustApi : RustApi {
|
||||
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
|
||||
var wipes: RustWipeListDto = RustWipeListDto()
|
||||
var online: RustOnlineDto = RustOnlineDto()
|
||||
var map: RustMapDto = RustMapDto()
|
||||
var mapLive: RustMapLiveDto = RustMapLiveDto()
|
||||
|
||||
/**
|
||||
* Answers that replace [mapLive] one call at a time, for a test that needs an
|
||||
* answer to arrive AFTER something else happened. Consumed first to last;
|
||||
* [mapLive] answers once it is empty.
|
||||
*/
|
||||
val mapLiveQueue: ArrayDeque<suspend () -> RustMapLiveDto> = ArrayDeque()
|
||||
|
||||
var serversCalls: Int = 0
|
||||
var eventCalls: Int = 0
|
||||
var leaderboardCalls: Int = 0
|
||||
var onlineCalls: Int = 0
|
||||
var wipeCalls: Int = 0
|
||||
var mapCalls: Int = 0
|
||||
var mapLiveCalls: Int = 0
|
||||
|
||||
/** The `kind` the last feed read carried — null means it sent none at all. */
|
||||
var lastKind: String? = null
|
||||
@@ -94,4 +107,17 @@ class FakeRustApi : RustApi {
|
||||
lastId = id
|
||||
return reply(online)
|
||||
}
|
||||
|
||||
override suspend fun getMap(id: String): RustMapDto {
|
||||
mapCalls++
|
||||
lastId = id
|
||||
return reply(map)
|
||||
}
|
||||
|
||||
override suspend fun getMapLive(id: String): RustMapLiveDto {
|
||||
mapLiveCalls++
|
||||
lastId = id
|
||||
error?.let { throw it }
|
||||
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||
import com.runicgateway.app.data.api.fake.FakeEventsApi
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** Which event a run on the map belongs to (D123, D125). */
|
||||
class EventRunResolverTest {
|
||||
|
||||
private val api = FakeEventsApi()
|
||||
private var clock = 1_000_000L
|
||||
private val resolver = EventRunResolver(EventsRepository(api), now = { clock })
|
||||
|
||||
private fun run(id: Long?, slug: String) = EventCalendarEntryDto(kind = "run", runId = id, slug = slug)
|
||||
|
||||
@Test
|
||||
fun `a listed run resolves to its event, matched as text against the plugin's string`() = runTest {
|
||||
// Core sends 41; the plugin sends "41". They are the same run.
|
||||
api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl")))
|
||||
assertEquals(mapOf("41" to "harbor-brawl"), resolver.resolve(setOf("41")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run the public calendar does not list stays unlinked`() = runTest {
|
||||
// A rehearsal or an unlisted event is absent from the calendar in SQL, so
|
||||
// absence is the whole gate: there is no second rule here.
|
||||
api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl")))
|
||||
assertEquals(emptyMap<String, String>(), resolver.resolve(setOf("99")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a projection and a core older than D125 name no run`() = runTest {
|
||||
api.calendar = EventCalendarDto(
|
||||
listOf(
|
||||
EventCalendarEntryDto(kind = "projected", slug = "weekly"),
|
||||
run(null, "old-core"),
|
||||
),
|
||||
)
|
||||
assertEquals(emptyMap<String, String>(), resolver.resolve(setOf("41")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `known ids never ask again`() = runTest {
|
||||
api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl")))
|
||||
repeat(5) { resolver.resolve(setOf("41")) }
|
||||
clock += 10 * EventRunResolver.REFRESH_MS
|
||||
resolver.resolve(setOf("41"))
|
||||
assertEquals(1, api.calendarCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown id re-reads at most once a minute`() = runTest {
|
||||
api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl")))
|
||||
resolver.resolve(setOf("41", "42"))
|
||||
assertEquals(1, api.calendarCalls)
|
||||
|
||||
// Every ten-second poll inside the minute: no new read.
|
||||
repeat(5) {
|
||||
clock += 10_000
|
||||
resolver.resolve(setOf("41", "42"))
|
||||
}
|
||||
assertEquals(1, api.calendarCalls)
|
||||
|
||||
// Past the minute, the new run is found.
|
||||
clock += 10_000
|
||||
api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl"), run(42, "crate-rush")))
|
||||
assertEquals(mapOf("41" to "harbor-brawl", "42" to "crate-rush"), resolver.resolve(setOf("41", "42")))
|
||||
assertEquals(2, api.calendarCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed read keeps what the last one found, and still counts as a read`() = runTest {
|
||||
api.calendar = EventCalendarDto(listOf(run(41, "harbor-brawl")))
|
||||
resolver.resolve(setOf("41"))
|
||||
|
||||
api.error = IOException("offline")
|
||||
clock += EventRunResolver.REFRESH_MS
|
||||
assertEquals(mapOf("41" to "harbor-brawl"), resolver.resolve(setOf("41", "42")))
|
||||
assertEquals(2, api.calendarCalls)
|
||||
|
||||
// Not asked again on the very next poll.
|
||||
clock += 10_000
|
||||
resolver.resolve(setOf("41", "42"))
|
||||
assertEquals(2, api.calendarCalls)
|
||||
}
|
||||
}
|
||||
213
app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt
Normal file
213
app/src/test/java/com/runicgateway/app/ui/rust/MapFrameTest.kt
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121).
|
||||
*
|
||||
* **These are the web's cases** (`module-rust/client/test/mapGeometry.test.js`),
|
||||
* so the phone and the page cannot put the same crate in two squares. The grid
|
||||
* cases are the GAME's answers: on 2026-09-25 a probe on the Oxide rig (a 3000
|
||||
* map, seed 1234) asked `MapHelper.PositionToString` for these positions and
|
||||
* wrote down what it said.
|
||||
*
|
||||
* One difference from the web, and it is deliberate: a canvas grows DOWN, so
|
||||
* [MapFrame.toPixel] answers y measured from the top, where Leaflet's frame
|
||||
* measures it up from the bottom.
|
||||
*/
|
||||
class MapFrameTest {
|
||||
|
||||
/** The rig's map, as `GET /map` describes it. */
|
||||
private val rig = MapFrame(
|
||||
RustMapGeometryDto(
|
||||
worldSize = 3000.0,
|
||||
oceanMargin = 500.0,
|
||||
width = 2500.0,
|
||||
height = 2500.0,
|
||||
gridCells = 20,
|
||||
gridCellSize = 150.0,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the grid label is the game's, at every probed position`() {
|
||||
val probed = listOf(
|
||||
Triple("ue_jungle_swamp_a", 764.7 to 167.4, "P8"),
|
||||
Triple("ue_jungle_swamp_a", 733.7 to -556.0, "O13"),
|
||||
Triple("harbor_2", 1122.7 to 204.6, "R8"),
|
||||
Triple("harbor_1", 678.1 to 1005.7, "O3"),
|
||||
Triple("ferry_terminal_1", 645.4 to -1004.1, "O16"),
|
||||
Triple("fishing_village_a", -787.8 to 224.1, "E8"),
|
||||
Triple("fishing_village_c", -203.0 to -911.1, "I16"),
|
||||
Triple("fishing_village_b", 1142.1 to -566.5, "R13"),
|
||||
Triple("desert_military_base_c", 94.0 to -729.3, "K14"),
|
||||
Triple("arctic_research_base_a", -556.9 to 840.0, "G4"),
|
||||
Triple("powerplant_1", -608.6 to -346.4, "F12"),
|
||||
Triple("water_treatment_plant_1", 497.8 to 84.9, "N9"),
|
||||
Triple("nw-corner", -1499.0 to 1499.0, "A0"),
|
||||
Triple("se-corner", 1499.0 to -1499.0, "T19"),
|
||||
Triple("origin", 0.0 to 0.0, "K10"),
|
||||
)
|
||||
for ((name, at, game) in probed) assertEquals(name, game, rig.gridLabel(at.first, at.second))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 146 point 3 m cell, phase 3's constant, would have disagreed with the game`() {
|
||||
val wrong = MapFrame(rig.geometry.copy(gridCells = 21, gridCellSize = 146.3))
|
||||
assertNotEquals("O16", wrong.gridLabel(645.4, -1004.1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `columns past Z are spelled the way Rust spells them`() {
|
||||
assertEquals("A", MapFrame.column(0))
|
||||
assertEquals("Z", MapFrame.column(25))
|
||||
assertEquals("AA", MapFrame.column(26))
|
||||
assertEquals("AB", MapFrame.column(27))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ocean margin is in pixels and is not scaled`() {
|
||||
assertEquals(0.5, rig.scale, 0.0)
|
||||
// The world's corners sit exactly one margin inside the picture's; the
|
||||
// south-west corner is at the BOTTOM left of a canvas.
|
||||
assertEquals(PicturePoint(500.0, 2000.0), rig.toPixel(-1500.0, -1500.0))
|
||||
assertEquals(PicturePoint(2000.0, 500.0), rig.toPixel(1500.0, 1500.0))
|
||||
assertEquals(PicturePoint(1250.0, 1250.0), rig.toPixel(0.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `north is up, so a larger z is higher on the screen, and x is across`() {
|
||||
val low = rig.toPixel(100.0, 100.0)
|
||||
val high = rig.toPixel(100.0, 400.0)
|
||||
assertTrue(high.y < low.y)
|
||||
assertEquals(low.x, high.x, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `something off the edge of the world is still placed, outside the picture`() {
|
||||
// The rig's cargo ship, as the probe found it: past the world AND the margin.
|
||||
val p = rig.toPixel(2691.6, -1453.1)
|
||||
assertTrue(p.x > rig.width)
|
||||
assertTrue(p.y > 0 && p.y < rig.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the app cannot assume the rig's size`() {
|
||||
// A 4500 m world is a 3250 px picture (§31.1), and its origin is its centre.
|
||||
val big = MapFrame(
|
||||
RustMapGeometryDto(worldSize = 4500.0, oceanMargin = 500.0, width = 3250.0, height = 3250.0),
|
||||
)
|
||||
assertEquals(PicturePoint(1625.0, 1625.0), big.toPixel(0.0, 0.0))
|
||||
assertEquals(PicturePoint(500.0, 2750.0), big.toPixel(-2250.0, -2250.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the grid has a line per edge and a label per cell, A0 at the north-west corner`() {
|
||||
val g = rig.grid()
|
||||
assertEquals(2 * (20 + 1), g.lines.size)
|
||||
assertEquals(20 * 20, g.labels.size)
|
||||
val a0 = g.labels.first { it.text == "A0" }
|
||||
assertEquals(-1500.0, a0.x, 0.0)
|
||||
assertEquals(1500.0, a0.z, 0.0)
|
||||
assertEquals(MapGrid(emptyList(), emptyList()), MapFrame(RustMapGeometryDto()).grid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a geometry that cannot place anything places nothing rather than NaN everywhere`() {
|
||||
val none = MapFrame(RustMapGeometryDto(worldSize = 0.0, width = 2500.0))
|
||||
assertEquals(0.0, none.scale, 0.0)
|
||||
assertFalse(none.canPlace)
|
||||
assertNull(MapFrame(RustMapGeometryDto(worldSize = 3000.0)).gridLabel(0.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hack timer reads as minutes and seconds`() {
|
||||
assertEquals("9:00", countdown(540))
|
||||
assertEquals("1:01", countdown(61.4))
|
||||
assertEquals("0:00", countdown(-3))
|
||||
assertEquals("0:00", countdown(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the picture's path is joined under api v1 as a relative path`() {
|
||||
// Relative, like every Retrofit path here, so a site under a prefix keeps it.
|
||||
assertEquals(
|
||||
"api/v1/public/rust/servers/main/map/image?v=28da6e8a",
|
||||
mapPictureUrl("/public/rust/servers/main/map/image?v=28da6e8a"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen transform ───────────────────────────────────────────────
|
||||
|
||||
private val phone = ViewSize(1080f, 1500f)
|
||||
private val picture = ViewSize(2500f, 2500f)
|
||||
|
||||
@Test
|
||||
fun `fit shows the whole picture, centred`() {
|
||||
val t = MapTransform.fit(phone, picture)
|
||||
assertEquals(1080f / 2500f, t.scale, 1e-6f)
|
||||
assertEquals(0f, t.offsetX, 1e-3f)
|
||||
assertEquals((1500f - 1080f) / 2, t.offsetY, 1e-3f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom stops at fit and at four times the picture's own pixels`() {
|
||||
val fit = MapTransform.fit(phone, picture)
|
||||
val tooFar = fit.transformed(phone, picture, 540f, 750f, 0f, 0f, 1000f)
|
||||
assertEquals(MapTransform.MAX_ZOOM, tooFar.scale, 0f)
|
||||
val tooNear = tooFar.transformed(phone, picture, 540f, 750f, 0f, 0f, 0.0001f)
|
||||
assertEquals(fit.scale, tooNear.scale, 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch keeps the picture pixel under the fingers where it was`() {
|
||||
val fit = MapTransform.fit(phone, picture)
|
||||
val px = fit.pictureX(300f)
|
||||
val py = fit.pictureY(900f)
|
||||
val zoomed = fit.transformed(phone, picture, 300f, 900f, 0f, 0f, 2f)
|
||||
assertEquals(px, zoomed.pictureX(300f), 1e-3)
|
||||
assertEquals(py, zoomed.pictureY(900f), 1e-3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pan cannot lose the map, but may follow something a little past its edge`() {
|
||||
val zoomed = MapTransform.fit(phone, picture).transformed(phone, picture, 540f, 750f, 0f, 0f, 4f)
|
||||
val flung = zoomed.transformed(phone, picture, 540f, 750f, -1e7f, -1e7f, 1f)
|
||||
// The view's centre stays within the picture plus a quarter of it.
|
||||
assertEquals(2500.0 * 1.25, flung.pictureX(540f), 1e-2)
|
||||
assertEquals(2500.0 * 1.25, flung.pictureY(750f), 1e-2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a box that grows keeps the reader's zoom and the place they were looking at`() {
|
||||
// The walk: the status line under the map went from two lines to one, the
|
||||
// box grew, and the zoom was thrown away.
|
||||
val zoomed = MapTransform.fit(phone, picture).transformed(phone, picture, 300f, 900f, 0f, 0f, 4f)
|
||||
val taller = ViewSize(1080f, 1560f)
|
||||
val centreBefore = zoomed.pictureX(540f) to zoomed.pictureY(750f)
|
||||
|
||||
val after = zoomed.resized(phone, taller, picture)
|
||||
|
||||
assertEquals(zoomed.scale, after.scale, 0f)
|
||||
assertEquals(centreBefore.first, after.pictureX(540f), 1e-2)
|
||||
assertEquals(centreBefore.second, after.pictureY(780f), 1e-2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a box that grows past the old zoom's floor is clamped up, not refitted from scratch`() {
|
||||
// Fitted to a small box, the scale is below what a larger box allows.
|
||||
val small = ViewSize(540f, 750f)
|
||||
val fitSmall = MapTransform.fit(small, picture)
|
||||
val after = fitSmall.resized(small, phone, picture)
|
||||
assertEquals(MapTransform.minScale(phone, picture), after.scale, 1e-6f)
|
||||
}
|
||||
}
|
||||
141
app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt
Normal file
141
app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustMapBaseDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapPlayerDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapWorldDto
|
||||
import com.runicgateway.app.data.api.dto.RustMonumentDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** What is drawn and what a tap means (D121, §31.4). */
|
||||
class MapMarkersTest {
|
||||
|
||||
private val geometry = RustMapGeometryDto(
|
||||
worldSize = 3000.0,
|
||||
oceanMargin = 500.0,
|
||||
width = 2500.0,
|
||||
height = 2500.0,
|
||||
gridCells = 20,
|
||||
gridCellSize = 150.0,
|
||||
)
|
||||
private val frame = MapFrame(geometry)
|
||||
private val all = MapLayer.entries.toSet()
|
||||
|
||||
@Test
|
||||
fun `a layer the server did not send draws nothing, whatever the switches say`() {
|
||||
// The gate is the server's: an absent layer is null on the wire, and the
|
||||
// switches being on cannot conjure it.
|
||||
val live = RustMapLiveDto(live = true, world = listOf(RustMapWorldDto("cargo", 1.0, 2.0)))
|
||||
val markers = mapMarkers(RustMapDto(geometry = geometry), live, all)
|
||||
assertEquals(listOf("cargo"), markers.map { it.kind })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switch the reader turned off hides a layer they were sent`() {
|
||||
val map = RustMapDto(geometry = geometry, monuments = listOf(RustMonumentDto(label = "Harbor", x = 1.0, z = 1.0)))
|
||||
val live = RustMapLiveDto(
|
||||
live = true,
|
||||
world = listOf(RustMapWorldDto("heli", 0.0, 0.0)),
|
||||
bases = listOf(RustMapBaseDto("tc", 5.0, 5.0)),
|
||||
)
|
||||
val markers = mapMarkers(map, live, all - MapLayer.WORLD)
|
||||
assertEquals(listOf(MapLayer.BASES), markers.map { it.layer })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the viewer's own dot and their mates are drawn last, on top`() {
|
||||
val live = RustMapLiveDto(
|
||||
live = true,
|
||||
players = listOf(RustMapPlayerDto(steamId = "1", name = "A", online = true)),
|
||||
mates = listOf(RustMapPlayerDto(steamId = "2", self = true, online = true)),
|
||||
events = listOf(RustMapEventDto(kind = "zone", runId = "41", radius = 60.0)),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(MapLayer.EVENTS, MapLayer.PLAYERS, MapLayer.MATES),
|
||||
mapMarkers(RustMapDto(geometry = geometry), live, all).map { it.layer },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player with no name yet is named by the id the layer carried`() {
|
||||
val live = RustMapLiveDto(players = listOf(RustMapPlayerDto(steamId = "76561198000000000", name = "")))
|
||||
assertEquals("76561198000000000", mapMarkers(RustMapDto(), live, all).single().name)
|
||||
}
|
||||
|
||||
// ── The tap ────────────────────────────────────────────────────────────
|
||||
|
||||
/** One screen pixel per picture pixel, no offset: screen = picture. */
|
||||
private val identity = MapTransform(1f, 0f, 0f)
|
||||
|
||||
private fun at(m: MapMarker) = frame.toPixel(m.x, m.z)
|
||||
|
||||
@Test
|
||||
fun `a tap within reach picks the nearest point, and one out of reach picks nothing`() {
|
||||
val near = MapMarker(MapLayer.PLAYERS, "player", 0.0, 0.0, name = "near")
|
||||
val far = MapMarker(MapLayer.PLAYERS, "player", 60.0, 0.0, name = "far")
|
||||
val p = at(near)
|
||||
|
||||
assertEquals(near, nearestMarker(listOf(far, near), frame, identity, p.x.toFloat() + 5, p.y.toFloat(), 24f))
|
||||
assertNull(nearestMarker(listOf(near), frame, identity, p.x.toFloat() + 40, p.y.toFloat(), 24f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a point inside a zone wins over the zone`() {
|
||||
// Otherwise a zone drawn round a monument would swallow every tap on it.
|
||||
val zone = MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41", radiusMetres = 100.0)
|
||||
val crate = MapMarker(MapLayer.EVENTS, "crate", 10.0, 0.0, runId = "41")
|
||||
val p = at(crate)
|
||||
assertEquals(crate, nearestMarker(listOf(zone, crate), frame, identity, p.x.toFloat(), p.y.toFloat(), 24f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zone answers a tap anywhere on its ground`() {
|
||||
// 100 m at the rig's 0.5 px per metre is 50 px of ground.
|
||||
val zone = MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41", radiusMetres = 100.0)
|
||||
val c = at(zone)
|
||||
assertEquals(zone, nearestMarker(listOf(zone), frame, identity, c.x.toFloat() + 45, c.y.toFloat(), 24f))
|
||||
assertNull(nearestMarker(listOf(zone), frame, identity, c.x.toFloat() + 80, c.y.toFloat(), 24f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `among equals the one drawn on top wins`() {
|
||||
val under = MapMarker(MapLayer.PLAYERS, "player", 0.0, 0.0, name = "under")
|
||||
val over = MapMarker(MapLayer.MATES, "mate", 0.0, 0.0, self = true)
|
||||
val p = at(under)
|
||||
assertEquals(over, nearestMarker(listOf(under, over), frame, identity, p.x.toFloat(), p.y.toFloat(), 24f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reach is on the screen, so zooming in narrows it on the ground`() {
|
||||
val a = MapMarker(MapLayer.BASES, "tc", 0.0, 0.0)
|
||||
val p = at(a)
|
||||
val zoomed = MapTransform(4f, 0f, 0f)
|
||||
// 10 picture px away is 40 screen px at 4x: out of a 24 px reach.
|
||||
val tapX = ((p.x + 10) * 4).toFloat()
|
||||
assertNull(nearestMarker(listOf(a), frame, zoomed, tapX, (p.y * 4).toFloat(), 24f))
|
||||
assertTrue(nearestMarker(listOf(a), frame, identity, (p.x + 10).toFloat(), p.y.toFloat(), 24f) == a)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the run ids are the events layer's, once each, and never blank`() {
|
||||
val live = RustMapLiveDto(
|
||||
events = listOf(
|
||||
RustMapEventDto(kind = "zone", runId = "41"),
|
||||
RustMapEventDto(kind = "crate", runId = "41"),
|
||||
RustMapEventDto(kind = "npc", runId = "42"),
|
||||
RustMapEventDto(kind = "npc", runId = ""),
|
||||
),
|
||||
)
|
||||
assertEquals(setOf("41", "42"), eventRunIds(live))
|
||||
assertEquals(emptySet<String>(), eventRunIds(RustMapLiveDto()))
|
||||
}
|
||||
}
|
||||
106
app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt
Normal file
106
app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The map's wire, decoded the way the app decodes it (`NetworkModule`).
|
||||
*
|
||||
* **Absent is not empty on this wire.** The module removes a layer the viewer may
|
||||
* not see, so a missing key means *not yours* and an empty list means *yours, and
|
||||
* nothing is there*. A default of `emptyList()` would blur the two, which is why
|
||||
* every layer is nullable.
|
||||
*/
|
||||
class RustMapDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a live answer with every layer absent decodes, and every layer is null`() {
|
||||
val live = json.decodeFromString<RustMapLiveDto>("""{"live":true,"mapKey":"3000.1234.1"}""")
|
||||
assertTrue(live.live)
|
||||
assertNull(live.world)
|
||||
assertNull(live.events)
|
||||
assertNull(live.players)
|
||||
assertNull(live.bases)
|
||||
assertNull(live.mates)
|
||||
assertEquals(emptyList<MapMarker>(), mapMarkers(RustMapDto(), live, MapLayer.entries.toSet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty layer is present, and different from an absent one`() {
|
||||
val live = json.decodeFromString<RustMapLiveDto>("""{"live":true,"world":[],"players":null}""")
|
||||
assertNotNull(live.world)
|
||||
assertTrue(live.world!!.isEmpty())
|
||||
assertNull(live.players)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a game that did not answer keeps its reason`() {
|
||||
val live = json.decodeFromString<RustMapLiveDto>("""{"live":false,"reason":"timeout"}""")
|
||||
assertFalse(live.live)
|
||||
assertEquals("timeout", live.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the map as the rig answers it`() {
|
||||
val map = json.decodeFromString<RustMapDto>(
|
||||
"""
|
||||
{"serverId":"main","mapKey":"3000.1234.1",
|
||||
"picture":{"path":"/public/rust/servers/main/map/image?v=28da6e8a","source":"companion","fetchedAt":null},
|
||||
"geometry":{"worldSize":3000,"oceanMargin":500,"width":2500,"height":2500,"gridCells":20,"gridCellSize":150,"background":"#0B3B4A"},
|
||||
"monuments":[{"value":"harbor_1#2","kind":"harbor_1","label":"Harbor","grid":"O3","x":678.1,"z":1005.7}],
|
||||
"layers":{"world":{"visible":true,"audience":"public"},"events":{"visible":true,"audience":"public"},
|
||||
"players":{"visible":false,"audience":"staff","cappedByPresence":true},"bases":{"visible":false,"audience":"staff"}},
|
||||
"mates":{"visible":false,"on":true,"linked":false,"signedIn":true},
|
||||
"pollMs":10000}
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertEquals(2500.0, map.geometry!!.width, 0.0)
|
||||
assertEquals("O3", map.monuments!!.single().grid)
|
||||
assertTrue(map.layers.players.cappedByPresence)
|
||||
assertFalse(map.layers.players.visible)
|
||||
assertTrue(map.anyLive)
|
||||
assertEquals(10_000L, map.pollMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that never described its map has no geometry and no picture`() {
|
||||
val map = json.decodeFromString<RustMapDto>("""{"serverId":"main","mapKey":null,"picture":null,"geometry":null}""")
|
||||
assertNull(map.geometry)
|
||||
assertNull(map.picture)
|
||||
assertFalse(map.anyLive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a site event's run id arrives as the plugin's string`() {
|
||||
val live = json.decodeFromString<RustMapLiveDto>(
|
||||
"""{"live":true,"events":[{"kind":"zone","runId":"41","x":1,"z":2,"radius":60,"name":"Harbor brawl"}]}""",
|
||||
)
|
||||
assertEquals("41", live.events!!.single().runId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar run carries core's run id as a number, and a projection none`() {
|
||||
// D125. A core older than it omits the field, which decodes to null.
|
||||
val calendar = json.decodeFromString<EventCalendarDto>(
|
||||
"""{"entries":[{"kind":"run","runId":41,"slug":"a"},{"kind":"projected","slug":"b"},{"kind":"run","slug":"c"}]}""",
|
||||
)
|
||||
assertEquals(listOf(41L, null, null), calendar.entries.map { it.runId })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLayerDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLayersDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapMatesDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapPlayerDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapWorldDto
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
import com.runicgateway.app.data.api.fake.FakeEventsApi
|
||||
import com.runicgateway.app.data.api.fake.FakeRustApi
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** One server's map: when it asks, and whose answer it draws (§31.2). */
|
||||
class RustMapViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val api = FakeRustApi()
|
||||
private val eventsApi = FakeEventsApi()
|
||||
|
||||
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
|
||||
override fun load(): StoredSession? = stored
|
||||
override fun save(session: StoredSession) { stored = session }
|
||||
override fun clear() { stored = null }
|
||||
}
|
||||
|
||||
private fun sessionFor(userId: Long?) = SessionManager(
|
||||
FakeTokenStore(userId?.let { StoredSession("a", "r", it, "u$it", "player") }),
|
||||
)
|
||||
|
||||
private fun user(id: Long) = SafeUserDto(id = id, username = "u$id", role = "player")
|
||||
|
||||
private fun viewModel(sessions: SessionManager = sessionFor(null)) = RustMapViewModel(
|
||||
RustRepository(api),
|
||||
EventsRepository(eventsApi),
|
||||
sessions,
|
||||
SavedStateHandle(mapOf("serverId" to "main")),
|
||||
)
|
||||
|
||||
private val geometry = RustMapGeometryDto(worldSize = 3000.0, oceanMargin = 500.0, width = 2500.0, height = 2500.0)
|
||||
private val public = RustMapLayerDto(visible = true, audience = "public")
|
||||
private val staffOnly = RustMapLayerDto(visible = false, audience = "staff")
|
||||
|
||||
private fun map(key: String = "3000.1234.1", mates: Boolean = false) = RustMapDto(
|
||||
serverId = "main",
|
||||
mapKey = key,
|
||||
geometry = geometry,
|
||||
layers = RustMapLayersDto(world = public, events = public, players = staffOnly, bases = staffOnly),
|
||||
mates = RustMapMatesDto(visible = mates, on = true, linked = mates, signedIn = mates),
|
||||
pollMs = 10_000,
|
||||
)
|
||||
|
||||
private fun mine(steamId: String) = RustMapLiveDto(
|
||||
live = true,
|
||||
mapKey = "3000.1234.1",
|
||||
mates = listOf(RustMapPlayerDto(steamId = steamId, x = 1.0, z = 1.0, online = true, self = true)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `it reads the map once and asks nothing that moves until the poll`() {
|
||||
api.map = map()
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.map is UiState.Success)
|
||||
assertEquals(1, api.mapCalls)
|
||||
assertEquals(0, api.mapLiveCalls)
|
||||
|
||||
vm.poll()
|
||||
assertEquals(1, api.mapLiveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a viewer sent nothing that moves is never polled`() {
|
||||
api.map = map().copy(layers = RustMapLayersDto(world = staffOnly, events = staffOnly, players = staffOnly, bases = staffOnly))
|
||||
val vm = viewModel()
|
||||
vm.poll()
|
||||
assertEquals(0, api.mapLiveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that never described its map is not polled`() {
|
||||
api.map = map().copy(geometry = null)
|
||||
val vm = viewModel()
|
||||
vm.poll()
|
||||
assertEquals(0, api.mapLiveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a module older than phase 14 is an error with a retry, not a crash`() {
|
||||
api.error = httpError(404)
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.map is UiState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed poll keeps the last positions drawn`() {
|
||||
api.map = map()
|
||||
api.mapLive = RustMapLiveDto(live = true, world = listOf(RustMapWorldDto("cargo", 1.0, 1.0)))
|
||||
val vm = viewModel()
|
||||
vm.poll()
|
||||
|
||||
api.mapLiveQueue.add { throw IOException("offline") }
|
||||
vm.poll()
|
||||
|
||||
assertEquals("cargo", vm.state.value.live?.world?.single()?.kind)
|
||||
assertTrue(vm.state.value.liveFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new map key re-reads the map without leaving the tab`() {
|
||||
api.map = map(key = "3000.1234.1")
|
||||
val vm = viewModel()
|
||||
|
||||
api.map = map(key = "3000.5678.2")
|
||||
api.mapLive = RustMapLiveDto(live = true, mapKey = "3000.5678.2")
|
||||
vm.poll()
|
||||
|
||||
assertEquals(2, api.mapCalls)
|
||||
assertEquals("3000.5678.2", (vm.state.value.map as UiState.Success).data.mapKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same map key does not re-read the map`() {
|
||||
api.map = map()
|
||||
api.mapLive = RustMapLiveDto(live = true, mapKey = "3000.1234.1")
|
||||
val vm = viewModel()
|
||||
repeat(3) { vm.poll() }
|
||||
assertEquals(1, api.mapCalls)
|
||||
}
|
||||
|
||||
// ── Per account ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `another account sees none of the first account's dots before its own answer`() {
|
||||
// Walk step 3, as a test. The server screen's back-stack entry outlives a
|
||||
// sign-out, and events phase 14b found a view model that loads once shows
|
||||
// the next account the previous one's answer without asking.
|
||||
val sessions = sessionFor(33)
|
||||
api.map = map(mates = true)
|
||||
api.mapLive = mine("first")
|
||||
val vm = viewModel(sessions)
|
||||
vm.poll()
|
||||
assertEquals("first", vm.state.value.live?.mates?.single()?.steamId)
|
||||
|
||||
sessions.onSignedIn("a2", "r2", user(34))
|
||||
|
||||
// Nothing of the first account's is left drawn, and the map was asked again.
|
||||
assertNull(vm.state.value.live)
|
||||
assertNull(vm.state.value.selected)
|
||||
assertEquals(2, api.mapCalls)
|
||||
|
||||
api.mapLive = mine("second")
|
||||
vm.poll()
|
||||
assertEquals("second", vm.state.value.live?.mates?.single()?.steamId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an answer asked for the previous account is thrown away when it lands`() {
|
||||
val sessions = sessionFor(33)
|
||||
api.map = map(mates = true)
|
||||
val vm = viewModel(sessions)
|
||||
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
api.mapLiveQueue.add {
|
||||
gate.await()
|
||||
mine("first")
|
||||
}
|
||||
vm.poll()
|
||||
|
||||
sessions.onSignedIn("a2", "r2", user(34))
|
||||
gate.complete(Unit)
|
||||
|
||||
assertNull(vm.state.value.live)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signing out still shows the public map, not an empty one`() {
|
||||
val sessions = sessionFor(33)
|
||||
api.map = map(mates = true)
|
||||
api.mapLive = mine("first")
|
||||
val vm = viewModel(sessions)
|
||||
vm.poll()
|
||||
|
||||
api.map = map(mates = false)
|
||||
sessions.onSignedOut()
|
||||
|
||||
assertNull(vm.state.value.live)
|
||||
assertTrue(vm.state.value.map is UiState.Success)
|
||||
assertEquals(false, (vm.state.value.map as UiState.Success).data.mates.visible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reader's switches survive a change of account`() {
|
||||
val sessions = sessionFor(33)
|
||||
api.map = map()
|
||||
val vm = viewModel(sessions)
|
||||
vm.toggle(MapLayer.GRID)
|
||||
|
||||
sessions.onSignedIn("a2", "r2", user(34))
|
||||
|
||||
assertTrue(MapLayer.GRID !in vm.state.value.shown)
|
||||
}
|
||||
|
||||
// ── The event link ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a listed run's marker resolves to its event, and a rehearsal's does not`() {
|
||||
api.map = map()
|
||||
api.mapLive = RustMapLiveDto(
|
||||
live = true,
|
||||
events = listOf(
|
||||
RustMapEventDto(kind = "zone", runId = "41", radius = 60.0, name = "Harbor brawl"),
|
||||
// A rehearsal: absent from the public calendar.
|
||||
RustMapEventDto(kind = "zone", runId = "77", radius = 60.0),
|
||||
),
|
||||
)
|
||||
eventsApi.calendar = EventCalendarDto(listOf(EventCalendarEntryDto(kind = "run", runId = 41, slug = "harbor-brawl")))
|
||||
val vm = viewModel()
|
||||
vm.poll()
|
||||
|
||||
assertEquals(mapOf("41" to "harbor-brawl"), vm.state.value.eventSlugs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hiding a layer closes the card for a marker on it`() {
|
||||
api.map = map()
|
||||
val vm = viewModel()
|
||||
vm.select(MapMarker(MapLayer.EVENTS, "zone", 0.0, 0.0, runId = "41"))
|
||||
|
||||
vm.toggle(MapLayer.EVENTS)
|
||||
|
||||
assertNull(vm.state.value.selected)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user