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

@@ -0,0 +1,153 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the four Protocol 3.0 content DTOs, against payloads captured from a
* **live** server rather than hand-written to match the Kotlin types.
*
* These exist because the fakes in `data/api/fake/` construct DTOs directly, so no test
* in the suite ever fed one real JSON — and `AtlasCreatureDto.places` shipped typed
* `List<String>` while the server sends objects. That decodes to an exception, the
* screen renders "something went wrong on the server", and 336 green tests say nothing.
* Nullable-with-defaults protects against a *missing* field, never a *wrong type*.
*/
class ShardContentDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
// ── Spawn atlas (§6) ─────────────────────────────────────────────────
/** Trimmed from `GET /api/v1/public/atlas/creatures/seaserpent` on a real shard. */
private val seaSerpent = """
{"slug":"seaserpent","name":"SeaSerpent","total":6048,"points":477,
"facets":{"Felucca":237,"Trammel":240},"art":"seaserpent.png",
"places":[{"facet":"Felucca","label":"Wilderness","spawners":91,"maxAlive":1194},
{"facet":"Trammel","label":"Britain","spawners":12,"maxAlive":96}],
"spawners":[{"id":1617,"facet":"Felucca","name":"SeaLife#68","x":1691,"y":1623,
"width":350,"height":350,"range":175,"maxCount":15,"minDelay":300,
"maxDelay":600,"todStart":0,"todEnd":0,"todMode":0,
"region":"Britain","landmark":null,"label":"Britain"}],
"spawnersTruncated":true,
"alsoHere":[{"slug":"waterelemental","name":"WaterElemental","shared":242}]}
""".trimIndent()
@Test fun atlasCreatureDetailDecodesTheRealPayload() {
val creature = json.decodeFromString<AtlasCreatureDto>(seaSerpent)
assertEquals("seaserpent", creature.slug)
assertEquals(6048, creature.total)
assertEquals(477, creature.points)
assertEquals(240, creature.facets["Trammel"])
assertTrue(creature.spawnersTruncated)
assertEquals("seaserpent.png", creature.art)
}
@Test fun atlasPlacesAreObjectsNotStrings() {
// The regression. `places` is the aggregate the screen exists to show, and it
// arrives as {facet,label,spawners,maxAlive} — never as a bare place name.
val places = json.decodeFromString<AtlasCreatureDto>(seaSerpent).places
assertEquals(2, places.size)
assertEquals("Wilderness", places[0].label)
assertEquals("Felucca", places[0].facet)
assertEquals(91, places[0].spawners)
assertEquals(1194, places[0].maxAlive)
}
@Test fun atlasCreatureSurvivesAProjectedOrEmptyPayload() {
// The search route sends no `places`/`spawners`/`art`, and the visibility
// framework can drop any field from any of them.
val lean = json.decodeFromString<AtlasCreatureDto>("""{"slug":"orc"}""")
assertEquals("orc", lean.slug)
assertTrue(lean.places.isEmpty())
assertTrue(lean.spawners.isEmpty())
assertNull(lean.art)
val bare = json.decodeFromString<AtlasCreatureDto>("""{"places":[{}]}""")
assertNull(bare.places[0].label)
assertNull(bare.places[0].spawners)
}
// ── Market (§8) ──────────────────────────────────────────────────────
@Test fun marketListingDecodesWithItsNestedVendorAndLocation() {
// `location` nests on the wire so one visibility rule covers map/x/y/region/house.
val listing = json.decodeFromString<MarketListingDto>(
"""{"serial":"0x40014A57","itemId":3937,"hue":1878,"amount":1,"price":115,
"name":null,"cliloc":1023937,"displayName":"longsword","child":false,
"vendor":{"serial":"0x2CB","shopName":"Seed Shop 225","ownerSerial":"0x201",
"ownerName":"Seed004A",
"location":{"map":"Felucca","x":1562,"y":1604,"z":0,
"region":"Britain","house":"Seed House 4"}}}""",
)
assertEquals("longsword", listing.displayName)
assertEquals(115L, listing.price)
assertEquals("Seed Shop 225", listing.vendor?.shopName)
assertEquals("Felucca", listing.vendor?.location?.map)
}
@Test fun marketListingSurvivesTheFieldsAVisitorMayNotSee() {
// Below the `staff` rung the server omits ownerName/ownerSerial, and below
// `player` the whole nested location. Neither may break the decode.
val projected = json.decodeFromString<MarketListingDto>(
"""{"serial":"0x40014A57","price":115,"displayName":"longsword",
"vendor":{"serial":"0x2CB","shopName":"Seed Shop 225"}}""",
)
assertEquals("Seed Shop 225", projected.vendor?.shopName)
assertNull(projected.vendor?.ownerName)
assertNull(projected.vendor?.location)
}
// ── Points boards (§7) ───────────────────────────────────────────────
@Test fun pointsBoardDecodesAnEmptyBoardAndItsCap() {
// A shard with nothing scored yet is the common case, not an error, and
// maxPoints 0 is the "uncapped" sentinel rather than a cap of zero.
val board = json.decodeFromString<PointsBoardDto>(
"""{"kind":"points.board","system":"QueensLoyalty","nameNumber":1095163,
"nameString":null,"players":0,"maxPoints":15000,"showOnGump":true,
"top":[],"t":1785556444154,"updatedAt":"2026-08-01T03:54:04.000Z"}""",
)
assertEquals("QueensLoyalty", board.system)
assertEquals(15000L, board.maxPoints)
assertNull(board.nameString)
assertTrue(board.top.isEmpty())
}
// ── Ruleset (§5) ─────────────────────────────────────────────────────
@Test fun rulesetDecodesTheNestedSectionsAndTolerantlySkipsUnknownOnes() {
// The frame is built from an allowlist that grows with the shard's config; a
// key this client has never heard of must not break the rules page.
val ruleset = json.decodeFromString<RulesetDto>(
"""{"kind":"world.ruleset","shard":"My Shard","expansion":"EJ",
"caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125},
"systems":{"factions":false,"vvv":true,"siege":false},
"accounts":{"charSlots":7,"perIp":3},
"somethingAddedLater":{"nested":true}}""",
)
assertEquals("My Shard", ruleset.shard)
assertEquals("EJ", ruleset.expansion)
// Caps arrive in tenths; the DTO's computed property is what the screen shows.
assertEquals(7000, ruleset.caps?.totalSkill)
assertEquals(700.0, ruleset.caps?.totalSkillCap!!, 0.0)
assertEquals(false, ruleset.systems["factions"])
assertEquals(true, ruleset.systems["vvv"])
}
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.shard
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.data.api.dto.MarketListingDto
import com.runicgateway.app.data.api.dto.MarketLocationDto
@@ -161,6 +162,17 @@ class ShardContentHelpersTest {
)
}
@Test fun placeLabelUsesTheServersResolvedNameAndFallsBackToTheFacet() {
assertEquals(
"Isamu-Jima",
placeLabel(AtlasPlaceDto(facet = "Tokuno", label = "Isamu-Jima", spawners = 4)),
)
// The server already falls back to "Wilderness", so a label-less place is the
// degenerate case; the facet still says something, an empty row does not.
assertEquals("Tokuno", placeLabel(AtlasPlaceDto(facet = "Tokuno")))
assertEquals("Tokuno", placeLabel(AtlasPlaceDto(facet = "Tokuno", label = " ")))
}
@Test fun facetSummaryLeadsWithWhereItMostlyIs() {
val creature = AtlasCreatureDto(
slug = "lizardman",