fix(shard): decode the atlas places objects and render them

`AtlasCreatureDto.places` was typed `List<String>` while the server sends
`{facet, label, spawners, maxAlive}` objects. The detail route answers 200 with
~49 KB, kotlinx throws on decode, and the screen renders "Something went wrong
on the server" — so the whole Atlas creature page was dead, and the error
blamed a server that was fine. Nullable-with-defaults protects against a
missing field, never a wrong element type.

Adds AtlasPlaceDto, plus the `art` field the server also sends, so a decode
cannot depend on that staying absent (neither client renders art yet).

`places` was never rendered either, so the aggregate the atlas exists to give —
"Shrines, Isamu-Jima, Yew", resolved server-side by point-in-rect — was missing
from the app while the web page led with it. Adds a "Where it spawns" section
above the individual spawners, matching web's ordering, and a plural for the
spawner count now that single-spawner places are on screen in bulk.

Adds ShardContentDtoTest — the first decode test any of the four Protocol 3.0
DTOs has had, fed payloads captured from a live server. That absence is the
root cause: the fakes in data/api/fake/ construct DTOs in Kotlin, so no test in
the suite could see a wire mismatch, even though PLAN.md §9 already required
"DTO decode for each new shape".

Also renders a placeholder row on an unscored leaderboard (the instance name,
em dash where a score goes) rather than a blank card — deliberately not shaped
like a real entry, since a placeholder that looked like a standing would be a
fabricated one.

Found by the on-device five-rung walk against a live shard; all four screens
re-verified on the emulator afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
This commit is contained in:
2026-08-01 00:59:23 -05:00
parent 06b6b015c2
commit 4f85021be2
7 changed files with 289 additions and 8 deletions

View File

@@ -287,14 +287,42 @@ data class AtlasCreatureDto(
val points: Int? = null,
/** Spawner count per facet. */
val facets: Map<String, Int> = emptyMap(),
/** Region/landmark names where it appears — the detail route only. */
val places: List<String> = emptyList(),
/**
* Where it appears, aggregated per named place — the detail route only, and the
* answer the whole screen exists to give. **Objects, not strings:** the server
* sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
* made the detail route fail to decode entirely.
*/
val places: List<AtlasPlaceDto> = emptyList(),
/**
* Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
* the normal state, since no artwork ships. Neither client renders it yet; the
* field is carried so a decode never depends on that staying true.
*/
val art: String? = null,
val spawners: List<AtlasSpawnerDto> = emptyList(),
val spawnersTruncated: Boolean = false,
/** Creatures sharing its spawners — the detail route only. */
val alsoHere: List<AtlasCreatureDto> = emptyList(),
)
/**
* One named place a creature spawns in, already aggregated across its spawners.
*
* [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
* "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
* "Wilderness" — turning a list of coordinates into an answer.
*/
@Serializable
data class AtlasPlaceDto(
val facet: String? = null,
val label: String? = null,
/** Spawners in this place. */
val spawners: Int? = null,
/** How many can be alive at once here, summed across those spawners. */
val maxAlive: Int? = null,
)
/**
* One spawn point.
*

View File

@@ -319,7 +319,7 @@ private fun RunicNavHost(
// here" from its own 404/403, so a deep link to a gated feature still lands on
// an honest answer even though the menu hides the entry.
composable(Routes.SHARD_RULES) { RulesScreen() }
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen() }
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
composable(Routes.SHARD_MARKET) {
MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
@@ -32,6 +33,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasPlaceDto
import com.runicgateway.app.data.api.dto.AtlasSpawnerDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
@@ -111,7 +113,7 @@ private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) ->
// and only the detail route sends it.
creature.points?.let {
Text(
text = stringResource(R.string.atlas_spawner_count, it),
text = pluralStringResource(R.plurals.atlas_spawner_count, it, it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -178,6 +180,17 @@ fun AtlasCreatureScreen(
}
}
}
// The aggregate comes first: "where is it" is the question, and the
// individual coordinates below are the follow-up. Same ordering as web.
if (creature.places.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_places)) }
items(
creature.places,
key = { "${it.facet.orEmpty()}:${it.label.orEmpty()}" },
) { place ->
PlaceRow(place)
}
}
if (creature.spawners.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_spawners)) }
items(creature.spawners, key = { it.id ?: it.hashCode().toLong() }) { spawner ->
@@ -208,6 +221,24 @@ fun AtlasCreatureScreen(
}
}
@Composable
private fun PlaceRow(place: AtlasPlaceDto) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
text = placeLabel(place),
style = MaterialTheme.typography.bodyMedium,
)
val meta = listOfNotNull(
place.facet,
place.spawners?.let { pluralStringResource(R.plurals.atlas_spawner_count, it, it) },
place.maxAlive?.let { stringResource(R.string.atlas_place_max_alive, it) },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun SpawnerRow(spawner: AtlasSpawnerDto) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
@@ -250,6 +281,14 @@ internal fun spawnerPlace(spawner: AtlasSpawnerDto): String {
}
}
/**
* The name of an aggregated place. [AtlasPlaceDto.label] is already the server's
* resolved answer and falls back to "Wilderness" there, so the only case left here is
* a place that carried no label at all — then the facet is better than nothing.
*/
internal fun placeLabel(place: AtlasPlaceDto): String =
place.label?.takeIf { it.isNotBlank() } ?: place.facet.orEmpty()
/**
* A creature's facets as one line, most spawners first — "where is it *mostly*" is the
* question a search result answers.

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PointsEntryDto
import com.runicgateway.app.ui.components.SectionLabel
@@ -32,6 +33,7 @@ import com.runicgateway.app.ui.components.SectionLabel
*/
@Composable
fun LeaderboardsScreen(
brand: BrandDto? = null,
modifier: Modifier = Modifier,
viewModel: LeaderboardsViewModel = hiltViewModel(),
) {
@@ -45,11 +47,11 @@ fun LeaderboardsScreen(
onRetry = viewModel::load,
key = { it.system.orEmpty() },
modifier = modifier,
) { board -> BoardCard(board) }
) { board -> BoardCard(board, placeholderName(brand)) }
}
@Composable
private fun BoardCard(board: PointsBoardDto) {
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@@ -75,9 +77,33 @@ private fun BoardCard(board: PointsBoardDto) {
}
if (board.top.isEmpty()) {
// A board nobody has scored on still gets a row, so the page reads as a
// set of standings waiting to be filled rather than a stack of blanks.
// It is deliberately NOT shaped like an entry — no rank, no score, the
// instance's own name — because a placeholder that looked like a real
// standing would be a fabricated one. The first real entry replaces it.
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Row(
Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = placeholderName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.leaderboards_no_score),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
stringResource(R.string.leaderboards_board_empty),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
@@ -129,3 +155,15 @@ internal fun boardLabel(board: PointsBoardDto): String {
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
}
/**
* The name to stand in for an empty board: this instance's, falling back to the app
* name — the same resolution the app bar uses, so a shard that publishes no branding
* still reads as *something* rather than as a blank row.
*
* Pure and separate so the fallback order is testable; [BrandDto.name] can be present
* but blank, which is a shard that set the key and left it empty.
*/
@Composable
internal fun placeholderName(brand: BrandDto?): String =
brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name)