feat(rust): named NPC markers, bigger bosses, and an event's progress lines (runicnpc stage 8) #54

Merged
whitlocktech merged 1 commits from feat/runicnpc-stage8 into edge 2026-10-06 13:32:58 +00:00
13 changed files with 246 additions and 13 deletions

View File

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

View File

@@ -122,7 +122,10 @@ data class RustMapLiveDto(
val mates: List<RustMapPlayerDto>? = 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,
)
/**

View File

@@ -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,9 +64,29 @@ 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(
@@ -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(

View File

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

View File

@@ -38,8 +38,18 @@ class EventRunResolver(
) {
private val mutex = Mutex()
private var slugs: Map<String, String> = emptyMap()
private var titles: Map<String, String> = 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<String>): Map<String, String> = 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 }
}
}

View File

@@ -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<MapLayer>): 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<MapLayer>): Li
name = it.name,
runId = it.runId?.takeIf { id -> id.isNotBlank() },
radiusMetres = it.radius,
boss = it.boss,
add = it.add,
),
)
}

View File

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

View File

@@ -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<String, String> = emptyMap(),
/** Run id → event title, from the same read: an NPC's card names its event (runicnpc D303). */
val eventTitles: Map<String, String> = 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) }
}
}

View File

@@ -550,6 +550,8 @@
<string name="events_part_of">Part of %1$s</string>
<string name="events_next">Next</string>
<string name="events_under_way">Under way</string>
<string name="events_progress_count">%1$s: %2$d of %3$d left</string>
<string name="events_progress_percent">%1$s: %2$d%%</string>
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
<string name="events_coming_up">Coming up</string>
@@ -628,6 +630,10 @@
<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_site_event_named">Site event · %1$s</string>
<string name="rust_map_boss">%1$s (boss)</string>
<string name="rust_map_boss_unnamed">Boss</string>
<string name="rust_map_boss_add">%1$s (a boss\'s add)</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>

View File

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

View File

@@ -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<String, String>(), 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")))

View File

@@ -138,4 +138,25 @@ class MapMarkersTest {
assertEquals(setOf("41", "42"), eventRunIds(live))
assertEquals(emptySet<String>(), 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 })
}
}

View File

@@ -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<RustMapLiveDto>(
"""{"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<EventOccurrenceDto>(
"""{"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<Any>(), json.decodeFromString<EventOccurrenceDto>("""{"runId":41}""").progress)
}
}