fix(shard): decode in-game serials as hex strings, not numbers
All checks were successful
PR Checks / android-build (pull_request) Successful in 5m44s

The public shard board DTOs typed in-game serials (and actor webId) as
Long, but the wire protocol (docs/link/INTEGRATION.md §1) sends them as
opaque hex strings ("0x1A2B"). The website returns board payloads
verbatim, so a guild leader / champ / governor carrying a hex serial
threw JsonDecodingException out of the Retrofit converter and crashed the
app on the Guilds/Champs/Governors boards. The API is the source of
truth, so the DTOs are corrected to match it.

- ActorDto.serial/webId, ChampDto.serial, HouseDto.serial,
  OnlineStaffDto.serial: Long -> String
- champ.remove / house.decay live frames now read serial via stringField;
  longField returned null on a hex serial, silently dropping every board
  removal and live IDOC update
- safeApiCall now catches SerializationException -> ErrorKind.SERVER, so
  any future contract drift degrades to a retry-able error instead of a
  crash (defense in depth)
- DTO + result tests updated to the real hex-string wire shapes

AI-assisted: authored with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-21 20:09:01 -05:00
parent d6d966882b
commit 1a14d47d5c
7 changed files with 72 additions and 19 deletions

View File

@@ -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 <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = 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 <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Ok(block())
@@ -46,4 +57,9 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = 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

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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,

View File

@@ -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).

View File

@@ -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")