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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)) })
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user