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:
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user