From e6aa9526c8c93b3d93332653259628fefb0bab2c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 08:23:17 -0500 Subject: [PATCH] feat(rust): named NPC markers, bigger bosses, and an event's progress lines (runicnpc stage 8) - The map (D302, D303), as the site draws it: - an NPC's card is its name, or "(boss)" or "(a boss's add)"; - the card names its event, with the title from the run resolver's existing calendar read; - a boss is bigger and magenta; - a boss outside any event is a world-layer marker with its name. - The event screen renders the live occurrence's progress lines (MODULE_API 1.12.0, D304): "Bandit: 2 of 3 left", "Walk Juggernaut: 59%". It re-reads quietly every 15 s while the event is live. - DTOs gain the new fields with defaults, so an older site decodes as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../app/data/api/dto/EventsDto.kt | 18 +++++++++ .../app/data/api/dto/RustMapDto.kt | 11 ++++- .../runicgateway/app/ui/events/EventScreen.kt | 40 ++++++++++++++++++- .../app/ui/events/EventViewModel.kt | 12 ++++++ .../app/ui/rust/EventRunResolver.kt | 16 ++++++-- .../runicgateway/app/ui/rust/MapMarkers.kt | 19 ++++++++- .../runicgateway/app/ui/rust/RustMapPanel.kt | 30 +++++++++++--- .../app/ui/rust/RustMapViewModel.kt | 5 ++- app/src/main/res/values/strings.xml | 6 +++ .../app/ui/events/EventProgressTest.kt | 35 ++++++++++++++++ .../app/ui/rust/EventRunResolverTest.kt | 16 ++++++++ .../app/ui/rust/MapMarkersTest.kt | 21 ++++++++++ .../app/ui/rust/RustMapDtoTest.kt | 30 ++++++++++++++ 13 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt index a89f932..097f341 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt @@ -88,6 +88,24 @@ data class EventOccurrenceDto( val scope: String? = null, val phase: String? = null, val resultsPublishedAt: String? = null, + /** + * On the live occurrence only: how its steps stand, in the words their modules + * chose (MODULE_API 1.12.0; runicnpc D304). Core interprets none of it, and nor + * does the app: each line is a label with a count or a percent. + */ + val progress: List = emptyList(), +) + +/** + * One progress line: a [label] with [left] of [of] ("Bandits: 3 of 8 left"), or a + * [percent] ("The Juggernaut: 62%"). Never both. + */ +@Serializable +data class EventProgressDto( + val label: String = "", + val left: Int? = null, + val of: Int? = null, + val percent: Int? = null, ) /** diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt index dc68bf8..c90b957 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustMapDto.kt @@ -122,7 +122,10 @@ data class RustMapLiveDto( val mates: List? = null, ) -/** A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply` or `crate`. */ +/** + * A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply`, `crate`, or a RunicNPC + * `boss` placed outside any event (runicnpc D302), with its [name]. + */ @Serializable data class RustMapWorldDto( val kind: String = "", @@ -131,6 +134,7 @@ data class RustMapWorldDto( /** A locked crate being hacked: seconds left. */ val hackLeftSec: Int? = null, val hacked: Boolean = false, + val name: String? = null, ) /** @@ -146,8 +150,13 @@ data class RustMapEventDto( val x: Double = 0.0, val z: Double = 0.0, val radius: Double? = null, + /** A zone's name, or a RunicNPC NPC's (runicnpc stage 8). */ val name: String? = null, val prefab: String? = null, + /** A RunicNPC boss: drawn bigger (D303). Its health is not sent. */ + val boss: Boolean = false, + /** One of a boss's adds, placed by the boss rather than by a step. */ + val add: Boolean = false, ) /** diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt index c9b0469..40ef0bd 100644 --- a/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt @@ -27,7 +27,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.EventOccurrenceDto import com.runicgateway.app.data.api.dto.EventParticipantDto +import com.runicgateway.app.data.api.dto.EventProgressDto import com.runicgateway.app.data.api.dto.PublicEventDto +import com.runicgateway.app.ui.PollWhileResumed import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.HtmlText @@ -62,10 +64,30 @@ fun EventScreen( when (val s = state) { is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) is UiState.Loading -> LoadingView(modifier) - is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier) + is UiState.Success -> { + // A live event's progress lines move (MODULE_API 1.12.0, runicnpc D304), + // so the page is re-read while it is live and on screen. + if (s.data.live) PollWhileResumed(intervalMs = PROGRESS_REFRESH_MS) { viewModel.refresh() } + EventBody(s.data, onOpenSeries, onOpenRun, modifier) + } } } +/** How often a live event's page is re-read, as the web page does. */ +private const val PROGRESS_REFRESH_MS = 15_000L + +/** + * One progress line as text: "Bandits: 3 of 8 left", or "The Juggernaut: 62%". + * Anything else renders nothing. Internal for the test. + */ +internal fun progressText(line: EventProgressDto, count: (String, Int, Int) -> String, percent: (String, Int) -> String): String? { + if (line.label.isBlank()) return null + val left = line.left + val of = line.of + if (left != null && of != null) return count(line.label, left, of) + return line.percent?.let { percent(line.label, it) } +} + @Composable private fun EventBody( event: PublicEventDto, @@ -196,6 +218,22 @@ private fun Headline(event: PublicEventDto) { style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) + // How it stands, in the words the steps' modules chose. + val countWords = stringResource(R.string.events_progress_count) + val percentWords = stringResource(R.string.events_progress_percent) + current.progress.forEach { line -> + progressText( + line, + count = { label, left, of -> countWords.format(label, left, of) }, + percent = { label, p -> percentWords.format(label, p) }, + )?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } event.next != null -> { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt index 0fa6d07..65906ed 100644 --- a/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt @@ -56,4 +56,16 @@ class EventViewModel @Inject constructor( _state.value = repository.event(slug, run).toUiState() } } + + /** + * Re-read while the event is live, for its progress lines (MODULE_API 1.12.0, + * runicnpc D304). Quiet: the page stays on screen, and a failed re-read keeps + * what is shown rather than replacing the page with an error. + */ + fun refresh() { + viewModelScope.launch { + val fresh = repository.event(slug, run).toUiState() + if (fresh is UiState.Success) _state.value = fresh + } + } } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt b/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt index 8ecea5e..d527711 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/EventRunResolver.kt @@ -38,8 +38,18 @@ class EventRunResolver( ) { private val mutex = Mutex() private var slugs: Map = emptyMap() + private var titles: Map = emptyMap() private var readAt: Long? = null + /** + * The event title for each of [runIds] the last read found (runicnpc D303: an NPC's + * card names its event). Never reads by itself: [resolve] decides when the calendar + * is asked, and this answers from what it found. + */ + suspend fun titles(runIds: Set): Map = mutex.withLock { + runIds.mapNotNull { id -> titles[id]?.let { id to it } }.toMap() + } + /** * The slug for each of [runIds] the public calendar lists. Ids it does not * list are absent from the answer. @@ -57,9 +67,9 @@ class EventRunResolver( 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 } + val runs = result.data.entries.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() } + slugs = runs.associate { it.runId.toString() to it.slug } + titles = runs.filter { it.title.isNotBlank() }.associate { it.runId.toString() to it.title } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt b/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt index e2de901..5eb4166 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt @@ -41,6 +41,10 @@ data class MapMarker( val online: Boolean = false, val sleeping: Boolean = false, val self: Boolean = false, + /** A RunicNPC boss, in an event or the world layer: drawn bigger (runicnpc D303). */ + val boss: Boolean = false, + /** One of a boss's adds. */ + val add: 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" @@ -60,7 +64,18 @@ fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set): Li 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)) + add( + MapMarker( + MapLayer.WORLD, + it.kind, + it.x, + it.z, + name = it.name, + hackLeftSec = it.hackLeftSec, + hacked = it.hacked, + boss = it.kind == "boss", + ), + ) } } if (MapLayer.EVENTS in shown) { @@ -74,6 +89,8 @@ fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set): Li name = it.name, runId = it.runId?.takeIf { id -> id.isNotBlank() }, radiusMetres = it.radius, + boss = it.boss, + add = it.add, ), ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt index e4fbb12..0b90702 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt @@ -187,6 +187,7 @@ private fun MapContent( marker = marker, grid = marker.grid ?: frame.gridLabel(marker.x, marker.z), eventSlug = marker.runId?.let { ui.eventSlugs[it] }, + eventTitle = marker.runId?.let { ui.eventTitles[it] }, onOpenEvent = onOpenEvent, onClose = { onSelect(null) }, modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp), @@ -404,9 +405,12 @@ 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) + // A RunicNPC boss outside any event (runicnpc D302): as big as a ship. + "boss" -> MarkerStyle(BOSS, 7, 2) else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5) } - MapLayer.EVENTS -> MarkerStyle(EVENT, 5) + // An event's boss is bigger (runicnpc D303). + MapLayer.EVENTS -> if (m.boss) MarkerStyle(BOSS, 8, 2) else 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) @@ -422,6 +426,7 @@ private fun MarkerCard( marker: MapMarker, grid: String?, eventSlug: String?, + eventTitle: String?, onOpenEvent: (String, String) -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier, @@ -429,7 +434,7 @@ private fun MarkerCard( 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 { + markerDetail(marker, eventTitle)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } grid?.let { @@ -465,11 +470,21 @@ private fun markerTitle(m: MapMarker): String = when (m.layer) { "bradley" -> stringResource(R.string.rust_map_bradley) "supply" -> stringResource(R.string.rust_map_supply) "crate" -> stringResource(R.string.rust_map_crate) + "boss" -> stringResource(R.string.rust_map_boss, m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_boss_unnamed)) 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) + // A RunicNPC NPC says its name, and whether it is a boss or a boss's add (D303). + "npc" -> { + val name = m.name?.takeIf { it.isNotBlank() } + when { + name == null -> stringResource(R.string.rust_map_event_npc) + m.boss -> stringResource(R.string.rust_map_boss, name) + m.add -> stringResource(R.string.rust_map_boss_add, name) + else -> name + } + } else -> stringResource(R.string.rust_map_event_crate) } MapLayer.PLAYERS -> m.name ?: "" @@ -479,7 +494,7 @@ private fun markerTitle(m: MapMarker): String = when (m.layer) { } @Composable -private fun markerDetail(m: MapMarker): String? = when (m.layer) { +private fun markerDetail(m: MapMarker, eventTitle: String? = null): String? = when (m.layer) { MapLayer.WORLD -> when { m.kind != "crate" -> null m.hacked -> stringResource(R.string.rust_map_crate_hacked) @@ -487,8 +502,10 @@ private fun markerDetail(m: MapMarker): String? = when (m.layer) { 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) + // calendar does not list gets this line and no button. One it lists names its + // event (runicnpc D303). + MapLayer.EVENTS -> eventTitle?.let { stringResource(R.string.rust_map_site_event_named, it) } + ?: 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) @@ -649,6 +666,7 @@ 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 BOSS = Color(0xFFD500F9) private val ONLINE = Color(0xFFFFFFFF) private val SLEEPING = Color(0xFF9E9E9E) private val TC = Color(0xFFFF7043) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt index 5c4b016..248ca4c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustMapViewModel.kt @@ -40,6 +40,8 @@ data class RustMapUi( val selected: MapMarker? = null, /** Run id → event slug, for the runs core's public calendar lists. */ val eventSlugs: Map = emptyMap(), + /** Run id → event title, from the same read: an NPC's card names its event (runicnpc D303). */ + val eventTitles: Map = emptyMap(), ) /** @@ -176,8 +178,9 @@ class RustMapViewModel @Inject constructor( val ids = eventRunIds(answer) if (ids.isEmpty()) return val slugs = resolver.resolve(ids) + val titles = resolver.titles(ids) if (asked != generation) return - _state.update { it.copy(eventSlugs = slugs) } + _state.update { it.copy(eventSlugs = slugs, eventTitles = titles) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 590478d..2f9e791 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -550,6 +550,8 @@ Part of %1$s Next Under way + %1$s: %2$d of %3$d left + %1$s: %2$d%% Nothing scheduled at the moment. This event has not been scheduled yet. Coming up @@ -628,6 +630,10 @@ Event crate Event NPC Site event + Site event · %1$s + %1$s (boss) + Boss + %1$s (a boss\'s add) Open event Tool cupboard Vending machine diff --git a/app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt b/app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt new file mode 100644 index 0000000..3f35fe5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/events/EventProgressTest.kt @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import com.runicgateway.app.data.api.dto.EventProgressDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A live event's progress lines (MODULE_API 1.12.0, runicnpc D304): a count or a + * percent with a label, and nothing else renders. + */ +class EventProgressTest { + + private fun text(line: EventProgressDto) = progressText( + line, + count = { label, left, of -> "$label: $left of $of left" }, + percent = { label, p -> "$label: $p%" }, + ) + + @Test + fun `a count and a percent read as the web reads them`() { + assertEquals("Bandits: 3 of 8 left", text(EventProgressDto("Bandits", left = 3, of = 8))) + assertEquals("The Juggernaut: 62%", text(EventProgressDto("The Juggernaut", percent = 62))) + } + + @Test + fun `no label, or neither shape, renders nothing`() { + assertNull(text(EventProgressDto("", left = 1, of = 2))) + assertNull(text(EventProgressDto("Odd"))) + assertNull(text(EventProgressDto("Half", left = 1))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt index 8fcc6c1..a7b2047 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/EventRunResolverTest.kt @@ -76,6 +76,22 @@ class EventRunResolverTest { assertEquals(2, api.calendarCalls) } + @Test + fun `the same read names each run's event, and titles never asks the calendar itself`() = runTest { + api.calendar = EventCalendarDto( + listOf( + EventCalendarEntryDto(kind = "run", runId = 41, slug = "harbour-raid", title = "Harbour Raid"), + EventCalendarEntryDto(kind = "run", runId = 42, slug = "untitled", title = ""), + ), + ) + assertEquals(emptyMap(), resolver.titles(setOf("41"))) + assertEquals(0, api.calendarCalls) + + resolver.resolve(setOf("41", "42")) + assertEquals(mapOf("41" to "Harbour Raid"), resolver.titles(setOf("41", "42", "99"))) + assertEquals(1, 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"))) diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt index 2b13ba4..cbd9479 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/MapMarkersTest.kt @@ -138,4 +138,25 @@ class MapMarkersTest { assertEquals(setOf("41", "42"), eventRunIds(live)) assertEquals(emptySet(), eventRunIds(RustMapLiveDto())) } + + @Test + fun `runicnpc D302 and D303 - an NPC's name, a boss and an add, and a boss outside any event`() { + val live = RustMapLiveDto( + live = true, + world = listOf(RustMapWorldDto(kind = "boss", x = 5.0, z = 5.0, name = "The Warden")), + events = listOf( + RustMapEventDto(kind = "npc", runId = "41", name = "The Juggernaut", boss = true), + RustMapEventDto(kind = "npc", runId = "41", name = "Bandit", add = true), + RustMapEventDto(kind = "npc", runId = "41", name = "Bandit"), + ), + ) + val markers = mapMarkers(RustMapDto(geometry = geometry), live, all) + val world = markers.single { it.layer == MapLayer.WORLD } + assertEquals("The Warden", world.name) + assertTrue(world.boss) + val npcs = markers.filter { it.layer == MapLayer.EVENTS } + assertEquals(listOf(true, false, false), npcs.map { it.boss }) + assertEquals(listOf(false, true, false), npcs.map { it.add }) + assertEquals(listOf("The Juggernaut", "Bandit", "Bandit"), npcs.map { it.name }) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt index 017539b..7287c63 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustMapDtoTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.rust import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventOccurrenceDto import com.runicgateway.app.data.api.dto.RustMapDto import com.runicgateway.app.data.api.dto.RustMapLiveDto import kotlinx.serialization.json.Json @@ -103,4 +104,33 @@ class RustMapDtoTest { ) assertEquals(listOf(41L, null, null), calendar.entries.map { it.runId }) } + + @Test + fun `runicnpc stage 8 - NPC rows and a world boss decode, and an older row without them still does`() { + val live = json.decodeFromString( + """{"live":true, + "world":[{"kind":"boss","x":1,"z":2,"name":"The Warden"},{"kind":"cargo","x":3,"z":4}], + "events":[{"kind":"npc","runId":"41","x":0,"z":0,"prefab":"profile:jugg","key":"k1","name":"The Juggernaut","boss":true}, + {"kind":"npc","runId":"41","x":0,"z":0,"name":"Bandit","add":true}, + {"kind":"zone","runId":"41","x":0,"z":0,"radius":50}]}""", + ) + assertEquals("The Warden", live.world!![0].name) + assertNull(live.world!![1].name) + val events = live.events!! + assertTrue(events[0].boss) + assertFalse(events[0].add) + assertTrue(events[1].add) + assertFalse(events[2].boss) + } + + @Test + fun `an occurrence's progress decodes, and an older answer without it is an empty list`() { + val withLines = json.decodeFromString( + """{"runId":41,"live":true,"progress":[{"label":"Bandits","left":3,"of":8},{"label":"The Juggernaut","percent":62}]}""", + ) + assertEquals(listOf("Bandits", "The Juggernaut"), withLines.progress.map { it.label }) + assertEquals(3, withLines.progress[0].left) + assertEquals(62, withLines.progress[1].percent) + assertEquals(emptyList(), json.decodeFromString("""{"runId":41}""").progress) + } } -- 2.49.1