diff --git a/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt index 2afd018..6f500a8 100644 --- a/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt +++ b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.core.result import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException import retrofit2.HttpException import java.io.IOException @@ -37,6 +38,16 @@ inline fun ApiResult.map(transform: (T) -> R): ApiResult = when (th * Run a suspending Retrofit call and normalize every outcome into an [ApiResult]. * Coroutine cancellation is rethrown so structured concurrency still works — it * is control flow, not a network failure. + * + * A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g. + * a live-shaped `guild.update` snapshot carrying an unexpected value) throws a + * [SerializationException] out of the Retrofit converter. That is a broken contract + * with the backend, not a bug to crash on: the request completed but the response is + * unusable — an invalid upstream response — so it is surfaced as a server-side error + * (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry", + * exactly the graceful-degradation the layer promises (never throw for an expected + * failure). Without this catch the exception escapes the collecting coroutine and + * takes down the whole app. */ suspend fun safeApiCall(block: suspend () -> T): ApiResult = try { ApiResult.Ok(block()) @@ -46,4 +57,9 @@ suspend fun safeApiCall(block: suspend () -> T): ApiResult = try { ApiResult.HttpError(e.code(), e.message()) } catch (e: IOException) { ApiResult.NetworkError(e) +} catch (e: SerializationException) { + ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message) } + +/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */ +private const val MALFORMED_RESPONSE_STATUS = 502 diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt index 16d6910..cece9e4 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt @@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject * `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5). * Presentation is text-only for v1 (no item icons / paperdoll). * - * In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on - * the public boards — these are separate endpoints with separate shapes. + * In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on + * the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers. */ // ── Game-account linking ───────────────────────────────────────────────────── diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt index e18f688..f748b69 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt @@ -15,13 +15,18 @@ import kotlinx.serialization.json.JsonObject * `*.update` frames on `/public/shard/stream` decode into these same DTOs. */ -/** A game actor (player/leader/governor) as embedded in board payloads. */ +/** + * A game actor (player/leader/governor) as embedded in board payloads. Per the wire + * spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys + * (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a + * string (e.g. `"9931"`) — both are decoded as strings, not parsed. + */ @Serializable data class ActorDto( - val serial: Long? = null, + val serial: String? = null, val name: String? = null, val acct: String? = null, - val webId: Long? = null, + val webId: String? = null, ) { /** Best display label for this actor. */ val label: String get() = name ?: acct ?: "Someone" @@ -73,7 +78,7 @@ data class FeedEventDto( */ @Serializable data class OnlineStaffDto( - val serial: Long? = null, + val serial: String? = null, val name: String? = null, val map: String? = null, val x: Int? = null, @@ -87,7 +92,7 @@ data class OnlineStaffDto( */ @Serializable data class HouseDto( - val serial: Long = 0, + val serial: String = "", val name: String? = null, val region: String? = null, val map: String? = null, @@ -104,7 +109,7 @@ data class HouseDto( */ @Serializable data class ChampDto( - val serial: Long = 0, + val serial: String = "", val category: String? = null, val type: String? = null, val name: String? = null, diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt index fe86e3a..20ea6c6 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt @@ -69,7 +69,9 @@ class ChampsViewModel @Inject constructor( private fun applyFrame(frame: ShardStreamEvent.Frame) { when (frame.kind) { "champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) } - "champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) } + // Serial is an opaque hex-string key ("0x…"), not a number — read as a + // string (reading it as a Long silently dropped every champ.remove). + "champ.remove" -> FrameFields.stringField(frame.data, "serial")?.let { board.remove(it) } else -> return } // Only republish when the board actually changed (Success state only). diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt index e3b7d2c..9fb2bae 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt @@ -69,7 +69,9 @@ class HousesViewModel @Inject constructor( private fun applyFrame(frame: ShardStreamEvent.Frame) { if (frame.kind != "house.decay") return - val serial = FrameFields.longField(frame.data, "serial") ?: return + // Serials are opaque hex-string keys ("0x…"), not numbers — read as a string + // (reading it as a Long silently dropped every live IDOC update). + val serial = FrameFields.stringField(frame.data, "serial") ?: return // `to` is the new decay stage; only IDOC belongs on the public board. val stage = FrameFields.stringField(frame.data, "to") ?: FrameFields.stringField(frame.data, "stage") diff --git a/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt b/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt index 69e4f65..2b8fc63 100644 --- a/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt +++ b/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt @@ -35,6 +35,20 @@ class ApiResultTest { assertTrue(result is ApiResult.NetworkError) } + /** + * A body the app can't decode (a field whose type doesn't match its DTO) throws a + * [SerializationException] out of the Retrofit converter. It must degrade to a + * server-side error the UI renders, not escape and crash the app — the guild-board + * crash this fixes. `502` folds to [ui.ErrorKind.SERVER] via `toUiState`. + */ + @Test fun serializationExceptionBecomesServerError() = runTest { + val result = safeApiCall { + throw kotlinx.serialization.SerializationException("Unexpected symbol 'm' at path: \$[0].members") + } + assertTrue(result is ApiResult.HttpError) + assertEquals(502, (result as ApiResult.HttpError).status) + } + @Test fun cancellationIsRethrown() = runTest { assertThrows(CancellationException::class.java) { kotlinx.coroutines.runBlocking { diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt index dc27c78..f886973 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt @@ -43,25 +43,31 @@ class ShardDtoTest { } @Test fun champUpdateFrameDecodesWithKindAndExtras() { - // A live champ.update frame: has `kind`, `serial`, and category extras. The - // `kind` field is ignored (not on the DTO) and the extras decode. + // A live champ.update frame: has `kind`, a hex-string `serial` (INTEGRATION.md + // §1 — serials are opaque hex keys, never numbers), and category extras. The + // `kind`/`rank`/`autoRestart` fields are ignored (not on the DTO); extras decode. val dto = json.decodeFromString( - """{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon", - "status":"active","active":true,"level":10,"maxKills":250,"kills":120, - "bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""", + """{"kind":"champ.update","serial":"0x40012345","category":"champion","name":"Barracoon", + "status":"active","active":true,"level":10,"rank":3,"maxKills":250,"kills":120, + "autoRestart":true,"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""", ) - assertEquals(12345L, dto.serial) + assertEquals("0x40012345", dto.serial) assertEquals("champion", dto.category) assertEquals(120, dto.kills) assertTrue(dto.active) } @Test fun guildFrameDecodesLeaderActor() { + // The leader actor carries a hex-string serial and a string webId (the linked + // site-user id) — the exact wire shape from INTEGRATION.md §7. val dto = json.decodeFromString( """{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12, - "online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""", + "online":3,"alliance":"Light", + "leader":{"serial":"0x1A2B","name":"Arthur","acct":"art","webId":"9931","player":true}}""", ) assertEquals(7L, dto.id) + assertEquals("0x1A2B", dto.leader?.serial) + assertEquals("9931", dto.leader?.webId) assertEquals("Arthur", dto.leader?.label) assertEquals(12, dto.members) } @@ -86,13 +92,21 @@ class ShardDtoTest { @Test fun houseDecodesPublicIdocShape() { val dto = json.decodeFromString( - """{"serial":999,"name":"Tower","region":"Britain","map":"Felucca", + """{"serial":"0x40001234","name":"Tower","region":"Britain","map":"Felucca", "x":1,"y":2,"z":3,"isIdoc":true}""", ) - assertEquals(999L, dto.serial) + assertEquals("0x40001234", dto.serial) assertTrue(dto.isIdoc) } + @Test fun onlineStaffDecodesHexSerial() { + val dto = json.decodeFromString( + """{"serial":"0x24C","name":"Darrow"}""", + ) + assertEquals("0x24C", dto.serial) + assertEquals("Darrow", dto.name) + } + @Test fun actorLabelFallsBackToAcctThenSomeone() { assertEquals("bob", ActorDto(acct = "bob").label) assertEquals("Someone", ActorDto().label)