feat(rust): named NPC markers, bigger bosses, and an event's progress lines (runicnpc stage 8)
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m36s
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m36s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user