feat(m2): public shard widgets + live SSE stream
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m4s
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m4s
Implements M2 of docs/android/PLAN.md §6.2 (functional pass): the public shard surface over /api/v1/public/shard/*, plus the live SSE feed with reconnect/backoff and graceful degradation (§7). - Shard DTOs (status/economy/feed/online/presence/champs/guilds/governors/ houses) mirroring public/shard.controller.js; ignoreUnknownKeys keeps additive backend fields safe, and the live *.update frames decode into the same board DTOs. - PublicApi: the /public/shard/* GETs (status, feed, economy, online, presence, champs, guilds, governors + history, houses). - ShardStreamClient: OkHttp SSE over /public/shard/stream. Unlike the browser EventSource it reconnects itself — a cold Flow<ShardStreamEvent> with growing backoff (reset on open), no read timeout for the idle keepalive, and clean teardown on cancel so a dropped feed degrades to "offline". - ShardRepository: typed ApiResult snapshot reads + the shared live feed and frame decoders. - Screens: a Shard hub (status/online count/economy/presence/staff + live activity feed with a live indicator) linking to live boards for champion spawns, guilds, governors (+ on-demand term history) and falling houses (IDOC). Boards seed from a snapshot then merge SSE deltas in place via a reusable LiveBoard, mirroring the website's merge semantics. Wired into the shared navigation drawer (§5); all strings externalized (§2). - Tests (28): DTO/frame decode, LiveBoard merge, event-text formatting, and SSE frame parsing. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Consumes the public live-event SSE stream (`GET /public/shard/stream`, safe kinds
|
||||
* only) and re-emits each frame as a [ShardStreamEvent] (PLAN.md §6.2, §7).
|
||||
*
|
||||
* Unlike the browser's `EventSource`, OkHttp's does **not** auto-reconnect, so the
|
||||
* reconnect/backoff loop lives here: on any disconnect the connection is torn down
|
||||
* and re-opened after a growing delay (reset once a connection opens), and while no
|
||||
* shard site is configured yet the flow simply idles. The stream is exposed as a
|
||||
* cold [Flow]; a `viewModelScope` collect opens it and cancellation closes it, so a
|
||||
* dropped feed degrades to "offline" rather than crashing.
|
||||
*/
|
||||
@Singleton
|
||||
class ShardStreamClient @Inject constructor(
|
||||
baseClient: OkHttpClient,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val json: Json,
|
||||
) {
|
||||
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
||||
// so the read timeout must be disabled or the idle stream would be killed.
|
||||
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
private val factory = EventSources.createFactory(sseClient)
|
||||
|
||||
/**
|
||||
* A cold flow of stream lifecycle + frame events, reconnecting with backoff
|
||||
* until the collector cancels. [ShardStreamEvent.Open] / [ShardStreamEvent.Closed]
|
||||
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||
* `{ kind, … }` payload the boards merge in place.
|
||||
*/
|
||||
fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
while (isActive) {
|
||||
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||
if (url == null) {
|
||||
// No shard site configured (or an unresolvable base) — idle, don't spin.
|
||||
trySend(ShardStreamEvent.Closed)
|
||||
delay(backoffMs)
|
||||
backoffMs = grow(backoffMs)
|
||||
continue
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
|
||||
val opened = AtomicBoolean(false)
|
||||
val ended = CompletableDeferred<Unit>()
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||
opened.set(true)
|
||||
trySend(ShardStreamEvent.Open)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String,
|
||||
) {
|
||||
parseFrame(data)?.let { (kind, obj) ->
|
||||
trySend(ShardStreamEvent.Frame(kind, obj))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
trySend(ShardStreamEvent.Closed)
|
||||
ended.complete(Unit)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?,
|
||||
) {
|
||||
trySend(ShardStreamEvent.Closed)
|
||||
ended.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
val source = factory.newEventSource(request, listener)
|
||||
try {
|
||||
// Park until this connection ends; collector cancellation propagates
|
||||
// out of await() and is handled by the finally + the while guard.
|
||||
ended.await()
|
||||
} finally {
|
||||
source.cancel()
|
||||
}
|
||||
|
||||
// A connection that opened before dropping reconnects promptly; a run of
|
||||
// failures that never opened backs off further to avoid hammering a down site.
|
||||
backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE `data:` line into `(kind, object)`, dropping keepalive comments
|
||||
* and any frame without a string `kind`. Kept internal + pure for unit testing.
|
||||
*/
|
||||
internal fun parseFrame(data: String): Pair<String, JsonObject>? {
|
||||
val trimmed = data.trim()
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
|
||||
return try {
|
||||
val obj = json.parseToJsonElement(trimmed).jsonObject
|
||||
val kindEl = obj["kind"] ?: return null
|
||||
if (kindEl is JsonNull) return null
|
||||
val kind = kindEl.jsonPrimitive.content
|
||||
if (kind.isEmpty()) null else kind to obj
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)
|
||||
|
||||
private companion object {
|
||||
const val STREAM_PATH = "api/v1/public/shard/stream"
|
||||
const val INITIAL_BACKOFF_MS = 2_000L
|
||||
const val MAX_BACKOFF_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* A lifecycle or data event from the public shard SSE stream (PLAN.md §6.2).
|
||||
*
|
||||
* - [Open] — a connection was established (drive the live indicator on).
|
||||
* - [Closed] — the connection dropped or none is available (indicator off);
|
||||
* [ShardStreamClient] will reconnect with backoff.
|
||||
* - [Frame] — a live event: its `kind` plus the raw JSON object, which the
|
||||
* boards decode into their DTO (`champ.update` → `ChampDto`, …).
|
||||
*/
|
||||
sealed interface ShardStreamEvent {
|
||||
data object Open : ShardStreamEvent
|
||||
data object Closed : ShardStreamEvent
|
||||
data class Frame(val kind: String, val data: JsonObject) : ShardStreamEvent
|
||||
}
|
||||
@@ -3,11 +3,21 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
@@ -24,8 +34,10 @@ import retrofit2.http.Url
|
||||
* The public (unauthenticated) surface consumed in M1: site status/settings,
|
||||
* news posts, CMS pages, wiki, and the contact form (PLAN.md §6.1). Paths are
|
||||
* relative to the sentinel base host; [com.runicgateway.app.core.net.HostSelectionInterceptor]
|
||||
* retargets them onto the configured shard site. Auth (§4) and the shard widgets
|
||||
* (§6.2) arrive in later milestones.
|
||||
* retargets them onto the configured shard site. The public shard widgets (§6.2)
|
||||
* are added in M2; auth (§4) and player game data (§6.3) arrive in later milestones.
|
||||
* The live SSE stream (`/public/shard/stream`) is not a Retrofit call — it is
|
||||
* consumed via OkHttp in [com.runicgateway.app.core.net.ShardStreamClient].
|
||||
*/
|
||||
interface PublicApi {
|
||||
|
||||
@@ -79,4 +91,41 @@ interface PublicApi {
|
||||
// ── Contact ──────────────────────────────────────────────────────────
|
||||
@POST("api/v1/public/contact")
|
||||
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||
|
||||
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||
@GET("api/v1/public/shard/status")
|
||||
suspend fun getShardStatus(): ShardStatusDto
|
||||
|
||||
@GET("api/v1/public/shard/feed")
|
||||
suspend fun getShardFeed(
|
||||
@Query("kind") kind: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): List<FeedEventDto>
|
||||
|
||||
@GET("api/v1/public/shard/economy")
|
||||
suspend fun getShardEconomy(@Query("limit") limit: Int? = null): List<EconomySampleDto>
|
||||
|
||||
@GET("api/v1/public/shard/online")
|
||||
suspend fun getShardOnline(): List<OnlineStaffDto>
|
||||
|
||||
@GET("api/v1/public/shard/presence")
|
||||
suspend fun getShardPresence(): PresenceDto
|
||||
|
||||
@GET("api/v1/public/shard/champs")
|
||||
suspend fun getShardChamps(): List<ChampDto>
|
||||
|
||||
@GET("api/v1/public/shard/guilds")
|
||||
suspend fun getShardGuilds(): List<GuildDto>
|
||||
|
||||
@GET("api/v1/public/shard/governors")
|
||||
suspend fun getShardGovernors(): List<GovernorDto>
|
||||
|
||||
@GET("api/v1/public/shard/governors/{city}/history")
|
||||
suspend fun getShardGovernorHistory(
|
||||
@Path("city") city: String,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): List<GovernorTermDto>
|
||||
|
||||
@GET("api/v1/public/shard/houses")
|
||||
suspend fun getShardHouses(): List<HouseDto>
|
||||
}
|
||||
|
||||
169
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
169
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* DTOs for the public shard widgets (PLAN.md §6.2). Shapes mirror the website's
|
||||
* `public/shard.controller.js` responses and the live SSE frames emitted by
|
||||
* `utils/shardBroadcast.js`. The champ/guild/governor board reads return the
|
||||
* stored event payload verbatim (a permissive object), so only the fields the app
|
||||
* renders are modeled; unknown keys are ignored by the JSON parser, and the live
|
||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||
*/
|
||||
|
||||
/** A game actor (player/leader/governor) as embedded in board payloads. */
|
||||
@Serializable
|
||||
data class ActorDto(
|
||||
val serial: Long? = null,
|
||||
val name: String? = null,
|
||||
val acct: String? = null,
|
||||
val webId: Long? = null,
|
||||
) {
|
||||
/** Best display label for this actor. */
|
||||
val label: String get() = name ?: acct ?: "Someone"
|
||||
}
|
||||
|
||||
/** A gold-supply sample (`economy.supply`), oldest → newest in the series. */
|
||||
@Serializable
|
||||
data class EconomySampleDto(
|
||||
val accounts: Int? = null,
|
||||
val gold: Double? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/shard/status` — connection state + online count + latest economy. */
|
||||
@Serializable
|
||||
data class ShardStatusDto(
|
||||
val enabled: Boolean = false,
|
||||
/** Sidecar link state: `connected` / `disconnected` / … */
|
||||
val status: String? = null,
|
||||
/** Whether the in-game plugin is currently connected to the sidecar. */
|
||||
val pluginConnected: Boolean = false,
|
||||
/** ISO timestamp of the last ingested event, or null. */
|
||||
val lastEventAt: String? = null,
|
||||
val onlineCount: Int = 0,
|
||||
val economy: EconomySampleDto? = null,
|
||||
) {
|
||||
/** True when the shard is live (link enabled and the plugin is connected). */
|
||||
val isOnline: Boolean get() = enabled && pluginConnected
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /public/shard/feed` — a stored notable event. The domain fields live under
|
||||
* [payload]; the live SSE frames carry those same fields at the top level (see
|
||||
* `ShardEventText`).
|
||||
*/
|
||||
@Serializable
|
||||
data class FeedEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "",
|
||||
val t: Long? = null,
|
||||
val bootId: String? = null,
|
||||
val payload: JsonObject? = null,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/shard/online` — a staff member currently in-world. Location is only
|
||||
* present for privileged viewers server-side; anonymous/app callers see name+serial.
|
||||
*/
|
||||
@Serializable
|
||||
data class OnlineStaffDto(
|
||||
val serial: Long? = null,
|
||||
val name: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A house on the public IDOC board (`GET /public/shard/houses`) — location only.
|
||||
* Owner/price/decay detail is staff-only and never reaches the app.
|
||||
*/
|
||||
@Serializable
|
||||
data class HouseDto(
|
||||
val serial: Long = 0,
|
||||
val name: String? = null,
|
||||
val region: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val isIdoc: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A champion-spawn board entry (`GET /public/shard/champs` + live `champ.update`).
|
||||
* Three families share the board (`category`: champion / mini / sea); the
|
||||
* category-specific fields are all nullable.
|
||||
*/
|
||||
@Serializable
|
||||
data class ChampDto(
|
||||
val serial: Long = 0,
|
||||
val category: String? = null,
|
||||
val type: String? = null,
|
||||
val name: String? = null,
|
||||
val status: String? = null,
|
||||
val active: Boolean = false,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val bossUp: Boolean = false,
|
||||
val boss: String? = null,
|
||||
val level: Int? = null,
|
||||
val maxLevel: Int? = null,
|
||||
val kills: Int? = null,
|
||||
val maxKills: Int? = null,
|
||||
val hits: Long? = null,
|
||||
val hitsMax: Long? = null,
|
||||
val restartAt: String? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** A guild board entry (`GET /public/shard/guilds` + live `guild.update`). */
|
||||
@Serializable
|
||||
data class GuildDto(
|
||||
val id: Long = 0,
|
||||
val name: String? = null,
|
||||
val abbr: String? = null,
|
||||
val members: Int? = null,
|
||||
val online: Int? = null,
|
||||
val alliance: String? = null,
|
||||
val leader: ActorDto? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** A town-governor board entry (`GET /public/shard/governors` + live `city.update`). */
|
||||
@Serializable
|
||||
data class GovernorDto(
|
||||
val city: String = "",
|
||||
val governor: ActorDto? = null,
|
||||
val governorElect: ActorDto? = null,
|
||||
val electionPhase: String? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** One term in a city's governor ledger (`GET /public/shard/governors/:city/history`). */
|
||||
@Serializable
|
||||
data class GovernorTermDto(
|
||||
val city: String? = null,
|
||||
val governor: ActorDto? = null,
|
||||
val startedAt: Long? = null,
|
||||
val endedAt: Long? = null,
|
||||
val votes: Int? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/shard/presence` — the online-population aggregate (live `presence.online`). */
|
||||
@Serializable
|
||||
data class PresenceDto(
|
||||
val count: Int = 0,
|
||||
val byFacet: Map<String, Int> = emptyMap(),
|
||||
val byRegion: Map<String, Int> = emptyMap(),
|
||||
val t: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The public shard widgets (PLAN.md §6.2): point-in-time board snapshots over
|
||||
* the `/public/shard/…` GETs plus the live SSE stream. Every read returns a typed
|
||||
* [ApiResult] so a down shard renders as offline (§7); [liveEvents] is the shared
|
||||
* SSE feed the boards merge in place. Live `*.update` frames decode into the same
|
||||
* DTOs as the snapshot reads via the `*Frame` decoders.
|
||||
*/
|
||||
@Singleton
|
||||
class ShardRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val stream: ShardStreamClient,
|
||||
private val json: Json,
|
||||
) {
|
||||
// ── Snapshots ────────────────────────────────────────────────────────
|
||||
suspend fun status(): ApiResult<ShardStatusDto> = safeApiCall { api.getShardStatus() }
|
||||
|
||||
suspend fun feed(limit: Int = 40): ApiResult<List<FeedEventDto>> =
|
||||
safeApiCall { api.getShardFeed(limit = limit) }
|
||||
|
||||
suspend fun economy(limit: Int = 100): ApiResult<List<EconomySampleDto>> =
|
||||
safeApiCall { api.getShardEconomy(limit) }
|
||||
|
||||
suspend fun online(): ApiResult<List<OnlineStaffDto>> = safeApiCall { api.getShardOnline() }
|
||||
|
||||
suspend fun presence(): ApiResult<PresenceDto> = safeApiCall { api.getShardPresence() }
|
||||
|
||||
suspend fun champs(): ApiResult<List<ChampDto>> = safeApiCall { api.getShardChamps() }
|
||||
|
||||
suspend fun guilds(): ApiResult<List<GuildDto>> = safeApiCall { api.getShardGuilds() }
|
||||
|
||||
suspend fun governors(): ApiResult<List<GovernorDto>> = safeApiCall { api.getShardGovernors() }
|
||||
|
||||
suspend fun governorHistory(city: String, limit: Int = 25): ApiResult<List<GovernorTermDto>> =
|
||||
safeApiCall { api.getShardGovernorHistory(city, limit) }
|
||||
|
||||
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
|
||||
|
||||
// ── Live stream ──────────────────────────────────────────────────────
|
||||
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||
|
||||
// Decode a live `*.update` frame into the board DTO it mirrors; null on shape
|
||||
// mismatch so a malformed frame is skipped rather than crashing the board.
|
||||
fun champFrame(obj: JsonObject): ChampDto? = decode(obj, ChampDto.serializer())
|
||||
fun guildFrame(obj: JsonObject): GuildDto? = decode(obj, GuildDto.serializer())
|
||||
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
||||
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
|
||||
|
||||
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||
json.decodeFromJsonElement(serializer, obj)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||
import com.runicgateway.app.ui.shard.HousesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -55,13 +61,14 @@ private val PUBLIC_MENU = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
)
|
||||
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.CONTACT, Routes.PAGE,
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -185,6 +192,22 @@ private fun RunicNavHost(
|
||||
) {
|
||||
PostScreen()
|
||||
}
|
||||
composable(Routes.SHARD) {
|
||||
ShardScreen(onOpenBoard = { board ->
|
||||
navController.navigate(
|
||||
when (board) {
|
||||
ShardBoard.CHAMPS -> Routes.SHARD_CHAMPS
|
||||
ShardBoard.GUILDS -> Routes.SHARD_GUILDS
|
||||
ShardBoard.GOVERNORS -> Routes.SHARD_GOVERNORS
|
||||
ShardBoard.HOUSES -> Routes.SHARD_HOUSES
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
composable(Routes.SHARD_CHAMPS) { ChampsScreen() }
|
||||
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
||||
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
||||
composable(Routes.SHARD_HOUSES) { HousesScreen() }
|
||||
composable(Routes.WIKI) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ object Routes {
|
||||
const val WIKI = "wiki"
|
||||
const val CONTACT = "contact"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
/** Shard boards, reachable from the hub. */
|
||||
const val SHARD_CHAMPS = "shard/champs"
|
||||
const val SHARD_GUILDS = "shard/guilds"
|
||||
const val SHARD_GOVERNORS = "shard/governors"
|
||||
const val SHARD_HOUSES = "shard/houses"
|
||||
|
||||
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
||||
const val PAGE = "page/{slug}"
|
||||
|
||||
|
||||
103
app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt
Normal file
103
app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.ChampDto
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
fun ChampsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ChampsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
|
||||
LiveBoardScreen(
|
||||
emptyMessage = stringResource(R.string.champs_empty),
|
||||
state = state,
|
||||
connected = connected,
|
||||
onRetry = viewModel::load,
|
||||
key = { it.serial },
|
||||
modifier = modifier,
|
||||
) { champ -> ChampCard(champ) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChampCard(champ: ChampDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = champ.name ?: champ.type ?: stringResource(R.string.champs_fallback_name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
champ.status?.let {
|
||||
Text(
|
||||
text = it.replaceFirstChar { c -> c.uppercase() },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
val detail = champDetail(champ)
|
||||
if (detail.isNotBlank()) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(champ.map, champ.x?.let { "(${champ.x}, ${champ.y})" }).joinToString(" ")
|
||||
if (where.isNotBlank()) {
|
||||
Text(
|
||||
text = where,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A short category-specific status line, mirroring the website's champ detail. */
|
||||
@Composable
|
||||
private fun champDetail(champ: ChampDto): String = when (champ.category) {
|
||||
"sea" -> if (champ.hitsMax != null) {
|
||||
"%,d / %,d hp".format(champ.hits ?: 0, champ.hitsMax)
|
||||
} else {
|
||||
champ.boss ?: champ.type ?: ""
|
||||
}
|
||||
"mini" -> stringResource(R.string.champ_level, champ.level ?: 0)
|
||||
else -> when (champ.status) {
|
||||
"active" -> if (champ.maxKills != null) {
|
||||
"%,d / %,d kills".format(champ.kills ?: 0, champ.maxKills)
|
||||
} else {
|
||||
stringResource(R.string.champ_level, champ.level ?: 0)
|
||||
}
|
||||
"cooldown" -> stringResource(R.string.champ_cooldown)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The champion-spawn board (PLAN.md §6.2): loaded once from `/public/shard/champs`,
|
||||
* then kept live by merging `champ.update` / `champ.remove` frames in place. Rows
|
||||
* are ordered by category then name for a stable display.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ChampsViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val board = LiveBoard<ChampDto> { it.serial.toString() }
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<ChampDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<ChampDto>>> = _state.asStateFlow()
|
||||
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.champs()) {
|
||||
is ApiResult.Ok -> {
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
when (event) {
|
||||
is ShardStreamEvent.Open -> _connected.value = true
|
||||
is ShardStreamEvent.Closed -> _connected.value = false
|
||||
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()) }
|
||||
else -> return
|
||||
}
|
||||
// Only republish when the board actually changed (Success state only).
|
||||
if (_state.value is UiState.Success) publish()
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val rows = board.values().sortedWith(
|
||||
compareBy({ it.category ?: "" }, { it.name ?: it.type ?: "" }),
|
||||
)
|
||||
_state.value = UiState.Success(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Minimal typed reads from a raw SSE frame [JsonObject] for the fields a board's
|
||||
* `*.remove` / `*.decay` delta needs when the whole frame doesn't warrant decoding
|
||||
* into a DTO (e.g. `champ.remove` carries only a `serial`). Returns null on a
|
||||
* missing or ill-typed field so a malformed frame is skipped, not fatal.
|
||||
*/
|
||||
object FrameFields {
|
||||
fun longField(obj: JsonObject, key: String): Long? {
|
||||
val el = obj[key]
|
||||
if (el !is JsonPrimitive || el is JsonNull) return null
|
||||
return el.content.toLongOrNull()
|
||||
}
|
||||
|
||||
fun stringField(obj: JsonObject, key: String): String? {
|
||||
val el = obj[key]
|
||||
if (el !is JsonPrimitive || el is JsonNull) return null
|
||||
return el.content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
|
||||
@Composable
|
||||
fun GovernorsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: GovernorsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
val history by viewModel.history.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
LiveChip(connected)
|
||||
}
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> {
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.governors_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
items(s.data, key = { it.city }) { city ->
|
||||
CityCard(
|
||||
city = city,
|
||||
terms = history[city.city],
|
||||
onExpand = { viewModel.loadHistory(city.city) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CityCard(
|
||||
city: GovernorDto,
|
||||
terms: List<GovernorTermDto>?,
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
expanded = !expanded
|
||||
if (expanded) onExpand()
|
||||
}
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(city.city, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = city.governor?.label?.let { stringResource(R.string.governor_current, it) }
|
||||
?: stringResource(R.string.governor_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
city.electionPhase?.takeIf { it.isNotBlank() && !it.equals("none", ignoreCase = true) }?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.governor_election, it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
HorizontalDivider()
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.governor_history),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
when {
|
||||
terms == null -> Text(
|
||||
stringResource(R.string.governor_history_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
terms.isEmpty() -> Text(
|
||||
stringResource(R.string.governor_history_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
else -> terms.forEach { term ->
|
||||
Text(
|
||||
text = term.governor?.label ?: stringResource(R.string.governor_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The town-governor board (PLAN.md §6.2): loaded from `/public/shard/governors`,
|
||||
* kept live by `city.update` frames (keyed by city — cities are fixed, so there is
|
||||
* no remove). Empty on shards without City Loyalty. A city's term ledger is fetched
|
||||
* on demand via [loadHistory] for the look-back panel.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class GovernorsViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val board = LiveBoard<GovernorDto> { it.city }
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<GovernorDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<GovernorDto>>> = _state.asStateFlow()
|
||||
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
/** Per-city expanded term history, loaded lazily; absent = not yet requested. */
|
||||
private val _history = MutableStateFlow<Map<String, List<GovernorTermDto>>>(emptyMap())
|
||||
val history: StateFlow<Map<String, List<GovernorTermDto>>> = _history.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.governors()) {
|
||||
is ApiResult.Ok -> {
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch (once) the term ledger for [city] for its expandable history panel. */
|
||||
fun loadHistory(city: String) {
|
||||
if (_history.value.containsKey(city)) return
|
||||
viewModelScope.launch {
|
||||
val terms = (repository.governorHistory(city) as? ApiResult.Ok)?.data ?: emptyList()
|
||||
_history.value = _history.value + (city to terms)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
when (event) {
|
||||
is ShardStreamEvent.Open -> _connected.value = true
|
||||
is ShardStreamEvent.Closed -> _connected.value = false
|
||||
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
if (frame.kind != "city.update") return
|
||||
val gov = repository.governorFrame(frame.data) ?: return
|
||||
if (gov.city.isBlank()) return
|
||||
board.upsert(gov)
|
||||
if (_state.value is UiState.Success) publish()
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val rows = board.values().sortedBy { it.city.lowercase() }
|
||||
_state.value = UiState.Success(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.GuildDto
|
||||
|
||||
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
fun GuildsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: GuildsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
|
||||
LiveBoardScreen(
|
||||
emptyMessage = stringResource(R.string.guilds_empty),
|
||||
state = state,
|
||||
connected = connected,
|
||||
onRetry = viewModel::load,
|
||||
key = { it.id },
|
||||
modifier = modifier,
|
||||
) { guild -> GuildCard(guild) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuildCard(guild: GuildDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = buildString {
|
||||
guild.abbr?.takeIf { it.isNotBlank() }?.let { append("[").append(it).append("] ") }
|
||||
append(guild.name ?: stringResource(R.string.guilds_fallback_name))
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
guild.members?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.guilds_members, it),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
guild.leader?.let { leader ->
|
||||
Text(
|
||||
text = stringResource(R.string.guilds_leader, leader.label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
guild.alliance?.takeIf { it.isNotBlank() }?.let { alliance ->
|
||||
Text(
|
||||
text = stringResource(R.string.guilds_alliance, alliance),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The guild board (PLAN.md §6.2): loaded from `/public/shard/guilds`, kept live by
|
||||
* `guild.update` / `guild.remove` frames. `guild.join` is a membership tick that
|
||||
* carries no board snapshot, so it is ignored here (the roster count refreshes on
|
||||
* the next `guild.update`). Rows are ordered by name.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class GuildsViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val board = LiveBoard<GuildDto> { it.id.toString() }
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<GuildDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<GuildDto>>> = _state.asStateFlow()
|
||||
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.guilds()) {
|
||||
is ApiResult.Ok -> {
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
when (event) {
|
||||
is ShardStreamEvent.Open -> _connected.value = true
|
||||
is ShardStreamEvent.Closed -> _connected.value = false
|
||||
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
when (frame.kind) {
|
||||
"guild.update" -> repository.guildFrame(frame.data)?.let { board.upsert(it) }
|
||||
"guild.remove" -> FrameFields.longField(frame.data, "id")?.let { board.remove(it.toString()) }
|
||||
else -> return
|
||||
}
|
||||
if (_state.value is UiState.Success) publish()
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val rows = board.values().sortedBy { it.name?.lowercase() ?: "" }
|
||||
_state.value = UiState.Success(rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.HouseDto
|
||||
|
||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||
@Composable
|
||||
fun HousesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HousesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
|
||||
LiveBoardScreen(
|
||||
emptyMessage = stringResource(R.string.houses_empty),
|
||||
state = state,
|
||||
connected = connected,
|
||||
onRetry = viewModel::load,
|
||||
key = { it.serial },
|
||||
modifier = modifier,
|
||||
) { house -> HouseCard(house) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: HouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = house.name?.takeIf { it.isNotBlank() }
|
||||
?: house.region
|
||||
?: stringResource(R.string.houses_fallback_name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.houses_idoc_badge),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(
|
||||
house.region,
|
||||
house.map,
|
||||
house.x?.let { "(${house.x}, ${house.y})" },
|
||||
).joinToString(" · ")
|
||||
if (where.isNotBlank()) {
|
||||
Text(
|
||||
text = where,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The public houses board (PLAN.md §6.2): the "where are the falling houses" view —
|
||||
* loaded from `/public/shard/houses` (IDOC only, location only) and kept live by
|
||||
* `house.decay` frames. A house entering IDOC adds/updates its row; any other decay
|
||||
* transition (refreshed, collapsed) drops it. Rows are ordered by region.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class HousesViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val board = LiveBoard<HouseDto> { it.serial.toString() }
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<HouseDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<HouseDto>>> = _state.asStateFlow()
|
||||
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.houses()) {
|
||||
is ApiResult.Ok -> {
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
when (event) {
|
||||
is ShardStreamEvent.Open -> _connected.value = true
|
||||
is ShardStreamEvent.Closed -> _connected.value = false
|
||||
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
if (frame.kind != "house.decay") return
|
||||
val serial = FrameFields.longField(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")
|
||||
if (stage?.equals("IDOC", ignoreCase = true) == true) {
|
||||
board.upsert(
|
||||
HouseDto(
|
||||
serial = serial,
|
||||
name = FrameFields.stringField(frame.data, "name"),
|
||||
region = FrameFields.stringField(frame.data, "region"),
|
||||
map = FrameFields.stringField(frame.data, "map"),
|
||||
x = FrameFields.longField(frame.data, "x")?.toInt(),
|
||||
y = FrameFields.longField(frame.data, "y")?.toInt(),
|
||||
z = FrameFields.longField(frame.data, "z")?.toInt(),
|
||||
isIdoc = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
board.remove(serial.toString())
|
||||
}
|
||||
if (_state.value is UiState.Success) publish()
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val rows = board.values().sortedBy { it.region?.lowercase() ?: "" }
|
||||
_state.value = UiState.Success(rows)
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt
Normal file
33
app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
/**
|
||||
* A live board keyed by a stable id: seeded from a snapshot read, then kept current
|
||||
* by SSE `*.update` / `*.remove` deltas (PLAN.md §6.2). Insertion order is
|
||||
* preserved (a [LinkedHashMap]) so re-seeding is deterministic; callers sort for
|
||||
* display. Not thread-safe — mutate it only from the owning ViewModel coroutine.
|
||||
*/
|
||||
class LiveBoard<T>(private val idOf: (T) -> String) {
|
||||
private val items = LinkedHashMap<String, T>()
|
||||
|
||||
/** Replace the whole board with a fresh snapshot. */
|
||||
fun seed(snapshot: List<T>) {
|
||||
items.clear()
|
||||
for (item in snapshot) items[idOf(item)] = item
|
||||
}
|
||||
|
||||
/** Insert or update one entry (a `*.update` frame). */
|
||||
fun upsert(item: T) {
|
||||
items[idOf(item)] = item
|
||||
}
|
||||
|
||||
/** Drop one entry by id (a `*.remove` frame). No-op if absent. */
|
||||
fun remove(id: String) {
|
||||
items.remove(id)
|
||||
}
|
||||
|
||||
/** The current board contents, in insertion order. */
|
||||
fun values(): List<T> = items.values.toList()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/**
|
||||
* A small "Live / Offline" indicator for the shard screens (PLAN.md §6.2): a dot +
|
||||
* label reflecting the SSE connection state. Green when connected, muted otherwise.
|
||||
*/
|
||||
@Composable
|
||||
fun LiveChip(connected: Boolean, modifier: Modifier = Modifier) {
|
||||
val color = if (connected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(if (connected) R.string.shard_live else R.string.shard_offline),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared scaffold for a live shard board (champs/guilds/houses): a [LiveChip]
|
||||
* header over the board list, folding [state] into loading/error/empty/content
|
||||
* (§7). Each caller supplies the per-row composable. Governors use their own layout
|
||||
* (expandable history), so they don't route through this.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> LiveBoardScreen(
|
||||
emptyMessage: String,
|
||||
state: UiState<List<T>>,
|
||||
connected: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
key: (T) -> Any,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (T) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
LiveChip(connected)
|
||||
}
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> {
|
||||
if (state.data.isEmpty()) {
|
||||
EmptyView(emptyMessage)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
items(state.data, key = key) { item -> row(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* One-line human descriptions of shard events for the live activity feed
|
||||
* (PLAN.md §6.2). Mirrors the website's `lib/shardEvents.js` `describe()` for the
|
||||
* public-safe kinds, so the app reads the same as the site. Accepts the event
|
||||
* fields as a [JsonObject] — the live SSE frame carries them at the top level;
|
||||
* a stored feed row carries them under `payload` (unwrap before calling).
|
||||
*/
|
||||
object ShardEventText {
|
||||
|
||||
fun describe(kind: String, fields: JsonObject): String = when (kind) {
|
||||
"player.death" ->
|
||||
"${nameOf(fields["who"])} was slain" + fields["killer"].let { if (isPresent(it)) " by ${nameOf(it)}" else "" }
|
||||
"player.murdered" ->
|
||||
"${nameOf(fields["victim"])} was murdered" + fields["murderer"].let { if (isPresent(it)) " by ${nameOf(it)}" else "" }
|
||||
"mob.killed" -> "${nameOf(fields["killer"])} killed ${nameOf(fields["killed"])}"
|
||||
"skill.gain" -> "${nameOf(fields["who"])} gained ${str(fields["skill"])}".trim()
|
||||
"fame.change" -> "${nameOf(fields["who"])}’s fame changed to ${num(fields["new"])}"
|
||||
"karma.change" -> "${nameOf(fields["who"])}’s karma changed to ${num(fields["new"])}"
|
||||
"quest.complete" -> "${nameOf(fields["who"])} completed “${str(fields["quest"])}”"
|
||||
"house.decay" -> {
|
||||
val name = str(fields["name"]).ifBlank { "A house" }
|
||||
val stage = str(fields["to"]).ifBlank { str(fields["stage"]) }
|
||||
val region = str(fields["region"])
|
||||
"$name is now $stage" + if (region.isNotBlank()) " — $region" else ""
|
||||
}
|
||||
"mob.login" -> "${nameOf(fields["who"])} entered the world"
|
||||
"mob.logout" -> "${nameOf(fields["who"])} left the world"
|
||||
"economy.supply" -> "Gold supply: ${num(fields["gold"])} across ${num(fields["accounts"])} accounts"
|
||||
"server.hello" -> "Shard online — ${num(fields["accounts"])} accounts, ${num(fields["mobiles"])} mobiles"
|
||||
"server.shutdown" -> "Shard shut down"
|
||||
"server.crashed" -> "Shard crashed" + str(fields["error"]).let { if (it.isNotBlank()) ": $it" else "" }
|
||||
"champ.update" -> {
|
||||
val where = str(fields["name"]).ifBlank { str(fields["type"]).ifBlank { "A champion spawn" } }
|
||||
when {
|
||||
str(fields["status"]) == "active" && bool(fields["bossUp"]) ->
|
||||
"$where: boss is up" + str(fields["boss"]).let { if (it.isNotBlank()) " ($it)" else "" }
|
||||
str(fields["status"]) == "active" -> "$where is active"
|
||||
str(fields["status"]) == "cooldown" -> "$where is on cooldown"
|
||||
else -> "$where is ${str(fields["status"]).ifBlank { "idle" }}"
|
||||
}
|
||||
}
|
||||
"champ.remove" -> "A champion spawn ended"
|
||||
"guild.update" -> "${str(fields["name"]).ifBlank { "A guild" }} updated"
|
||||
"guild.remove" -> "A guild disbanded"
|
||||
"guild.join" -> "${nameOf(fields["who"])} joined ${str(fields["guild"]).ifBlank { "a guild" }}".trim()
|
||||
"city.update" -> {
|
||||
val gov = fields["governor"]
|
||||
if (isPresent(gov)) "${str(fields["city"])} is governed by ${nameOf(gov)}"
|
||||
else "${str(fields["city"])} has no governor"
|
||||
}
|
||||
"presence.online" -> "${num(fields["count"])} players online"
|
||||
"region.enter" -> "${nameOf(fields["who"])} entered ${str(fields["region"]).ifBlank { "a region" }}".trim()
|
||||
else -> kind
|
||||
}
|
||||
|
||||
// ── field helpers ─────────────────────────────────────────────────────
|
||||
private fun isPresent(el: JsonElement?): Boolean = el != null && el !is JsonNull
|
||||
|
||||
/** Name of an actor that may be a bare string or a `{ name, acct }` object. */
|
||||
private fun nameOf(el: JsonElement?): String {
|
||||
if (!isPresent(el)) return "Someone"
|
||||
if (el is JsonPrimitive) return el.content.ifBlank { "Someone" }
|
||||
val obj = runCatching { el!!.jsonObject }.getOrNull() ?: return "Someone"
|
||||
return str(obj["name"]).ifBlank { str(obj["acct"]).ifBlank { "Someone" } }
|
||||
}
|
||||
|
||||
private fun str(el: JsonElement?): String =
|
||||
if (el is JsonPrimitive && el !is JsonNull) el.content else ""
|
||||
|
||||
private fun bool(el: JsonElement?): Boolean =
|
||||
el is JsonPrimitive && el.content.equals("true", ignoreCase = true)
|
||||
|
||||
/** Format a numeric field with thousands grouping; falls back to its raw text. */
|
||||
private fun num(el: JsonElement?): String {
|
||||
val raw = str(el)
|
||||
val d = raw.toDoubleOrNull() ?: return raw
|
||||
return if (d == d.toLong().toDouble()) "%,d".format(d.toLong()) else "%,.0f".format(d)
|
||||
}
|
||||
}
|
||||
208
app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt
Normal file
208
app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/** Board destinations reachable from the hub. */
|
||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
||||
|
||||
/**
|
||||
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
||||
* economy, presence, staff online, links to the boards, and a live activity feed.
|
||||
* The whole screen degrades gracefully — status drives loading/error, the feed
|
||||
* simply shows "offline" when the SSE stream is down (§7).
|
||||
*/
|
||||
@Composable
|
||||
fun ShardScreen(
|
||||
onOpenBoard: (ShardBoard) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ShardViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> HubContent(
|
||||
hub = s.data,
|
||||
feed = feed,
|
||||
connected = connected,
|
||||
onOpenBoard = onOpenBoard,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HubContent(
|
||||
hub: ShardHub,
|
||||
feed: List<FeedLine>,
|
||||
connected: Boolean,
|
||||
onOpenBoard: (ShardBoard) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
item { StatusCard(hub.status, hub.presence?.count) }
|
||||
item { BoardsCard(onOpenBoard) }
|
||||
|
||||
if (hub.online.isNotEmpty()) {
|
||||
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
||||
items(hub.online, key = { it.serial ?: it.name.hashCode().toLong() }) { staff ->
|
||||
StaffRow(staff)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SectionHeader(stringResource(R.string.shard_section_activity))
|
||||
LiveChip(connected)
|
||||
}
|
||||
}
|
||||
if (feed.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.shard_feed_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(feed, key = { it.id }) { line -> FeedRow(line) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (status.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val online = presenceCount ?: status.onlineCount
|
||||
Text(
|
||||
text = stringResource(R.string.shard_online_count, online),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
status.economy?.gold?.let { gold ->
|
||||
Text(
|
||||
text = stringResource(R.string.shard_economy_gold, "%,.0f".format(gold)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
|
||||
val boards = listOf(
|
||||
ShardBoard.CHAMPS to R.string.shard_nav_champs,
|
||||
ShardBoard.GUILDS to R.string.shard_nav_guilds,
|
||||
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
|
||||
ShardBoard.HOUSES to R.string.shard_nav_houses,
|
||||
)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
boards.forEachIndexed { index, (board, labelRes) ->
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenBoard(board) }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
if (index < boards.lastIndex) HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaffRow(staff: OnlineStaffDto) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = staff.name ?: "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
staff.map?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedRow(line: FeedLine) {
|
||||
Text(
|
||||
text = line.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** One row in the live activity feed. */
|
||||
data class FeedLine(val id: String, val kind: String, val text: String)
|
||||
|
||||
/** The hub's point-in-time data (status is the primary; the rest are best-effort). */
|
||||
data class ShardHub(
|
||||
val status: ShardStatusDto,
|
||||
val presence: PresenceDto?,
|
||||
val online: List<OnlineStaffDto>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Shard hub (PLAN.md §6.2): connection status + online count + latest economy
|
||||
* + presence + online staff, over a live activity feed. Status drives the screen's
|
||||
* load state; presence/online load best-effort (a partial outage still renders what
|
||||
* it can). The SSE feed reconnects on its own (§7) and toggles the live indicator.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ShardViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
||||
|
||||
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
||||
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
||||
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
private var seq = 0L
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
when (val status = repository.status()) {
|
||||
is ApiResult.Ok -> {
|
||||
// Presence/online are secondary — a failure there shouldn't blank the hub.
|
||||
val presence = (repository.presence() as? ApiResult.Ok)?.data
|
||||
val online = (repository.online() as? ApiResult.Ok)?.data ?: emptyList()
|
||||
_state.value = UiState.Success(ShardHub(status.data, presence, online))
|
||||
seedFeed()
|
||||
}
|
||||
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
||||
is ApiResult.HttpError -> _state.value = status.toUiState()
|
||||
is ApiResult.NetworkError -> _state.value = status.toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun seedFeed() {
|
||||
val events = (repository.feed(limit = 40) as? ApiResult.Ok)?.data ?: return
|
||||
_feed.value = events.map { ev ->
|
||||
FeedLine(
|
||||
id = "seed-${ev.id}",
|
||||
kind = ev.kind,
|
||||
text = ShardEventText.describe(ev.kind, ev.payload ?: kotlinx.serialization.json.JsonObject(emptyMap())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
when (event) {
|
||||
is ShardStreamEvent.Open -> _connected.value = true
|
||||
is ShardStreamEvent.Closed -> _connected.value = false
|
||||
is ShardStreamEvent.Frame -> {
|
||||
if (event.kind == "presence.online") updatePresence(event)
|
||||
prepend(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Patch the live presence snapshot in place so the hub's online count tracks the
|
||||
// stream between reloads (the `presence.online` frame decodes into PresenceDto).
|
||||
private fun updatePresence(frame: ShardStreamEvent.Frame) {
|
||||
val presence = repository.presenceFrame(frame.data) ?: return
|
||||
val current = _state.value as? UiState.Success ?: return
|
||||
_state.value = UiState.Success(current.data.copy(presence = presence))
|
||||
}
|
||||
|
||||
private fun prepend(frame: ShardStreamEvent.Frame) {
|
||||
val line = FeedLine(
|
||||
id = "live-${seq++}",
|
||||
kind = frame.kind,
|
||||
text = ShardEventText.describe(frame.kind, frame.data),
|
||||
)
|
||||
_feed.value = (listOf(line) + _feed.value).take(MAX_FEED)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_FEED = 40
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
<string name="menu_home">Home</string>
|
||||
<string name="menu_news">News</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_about">About</string>
|
||||
<string name="menu_contact">Contact</string>
|
||||
<string name="menu_change_server">Change server</string>
|
||||
@@ -73,4 +74,46 @@
|
||||
<string name="contact_fallback">This site has no mailer configured. Email directly: %1$s</string>
|
||||
<string name="contact_validation">Please fill in every field.</string>
|
||||
<string name="contact_error">Couldn\'t send your message. Please try again.</string>
|
||||
|
||||
<!-- ── Shard hub & live feed (§6.2) ─────────────────────────────── -->
|
||||
<string name="shard_live">Live</string>
|
||||
<string name="shard_offline">Offline</string>
|
||||
<string name="shard_status_online">Shard online</string>
|
||||
<string name="shard_status_offline">Shard offline</string>
|
||||
<string name="shard_online_count">%1$d players online</string>
|
||||
<string name="shard_economy_gold">Gold supply: %1$s</string>
|
||||
<string name="shard_section_staff">Staff online</string>
|
||||
<string name="shard_section_activity">Live activity</string>
|
||||
<string name="shard_feed_empty">No recent activity.</string>
|
||||
<string name="shard_nav_champs">Champion spawns</string>
|
||||
<string name="shard_nav_guilds">Guilds</string>
|
||||
<string name="shard_nav_governors">Governors</string>
|
||||
<string name="shard_nav_houses">Falling houses</string>
|
||||
|
||||
<!-- ── Champion spawns (§6.2) ──────────────────────────────────────── -->
|
||||
<string name="champs_empty">No champion spawns are being tracked right now.</string>
|
||||
<string name="champs_fallback_name">Champion spawn</string>
|
||||
<string name="champ_level">Level %1$d</string>
|
||||
<string name="champ_cooldown">Restarting</string>
|
||||
|
||||
<!-- ── Guilds (§6.2) ───────────────────────────────────────────────── -->
|
||||
<string name="guilds_empty">No guilds are being tracked right now.</string>
|
||||
<string name="guilds_fallback_name">Guild</string>
|
||||
<string name="guilds_members">%1$d members</string>
|
||||
<string name="guilds_leader">Led by %1$s</string>
|
||||
<string name="guilds_alliance">Alliance: %1$s</string>
|
||||
|
||||
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
|
||||
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
|
||||
<string name="governor_current">Governed by %1$s</string>
|
||||
<string name="governor_none">No governor</string>
|
||||
<string name="governor_election">Election: %1$s</string>
|
||||
<string name="governor_history">Term history</string>
|
||||
<string name="governor_history_loading">Loading…</string>
|
||||
<string name="governor_history_empty">No past terms recorded.</string>
|
||||
|
||||
<!-- ── Falling houses / IDOC (§6.2) ────────────────────────────────── -->
|
||||
<string name="houses_empty">No houses are in danger right now.</string>
|
||||
<string name="houses_fallback_name">A house</string>
|
||||
<string name="houses_idoc_badge">IDOC</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for SSE frame parsing (PLAN.md §6.2). The reconnect/backoff loop and
|
||||
* OkHttp EventSource wiring are integration concerns; the pure parse of a `data:`
|
||||
* payload into `(kind, object)` is unit-testable here.
|
||||
*/
|
||||
class ShardStreamClientTest {
|
||||
|
||||
private val client = ShardStreamClient(
|
||||
baseClient = OkHttpClient(),
|
||||
baseUrlHolder = BaseUrlHolder(),
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
|
||||
@Test fun parsesKindAndKeepsObject() {
|
||||
val parsed = client.parseFrame("""{"kind":"champ.update","serial":5,"status":"active"}""")
|
||||
assertEquals("champ.update", parsed?.first)
|
||||
assertEquals("5", parsed?.second?.get("serial").toString())
|
||||
}
|
||||
|
||||
@Test fun dropsKeepaliveComment() {
|
||||
assertNull(client.parseFrame(": ping"))
|
||||
assertNull(client.parseFrame(": connected"))
|
||||
}
|
||||
|
||||
@Test fun dropsBlank() {
|
||||
assertNull(client.parseFrame(" "))
|
||||
assertNull(client.parseFrame(""))
|
||||
}
|
||||
|
||||
@Test fun dropsFrameWithoutKind() {
|
||||
assertNull(client.parseFrame("""{"serial":5}"""))
|
||||
}
|
||||
|
||||
@Test fun dropsNullKind() {
|
||||
assertNull(client.parseFrame("""{"kind":null}"""))
|
||||
}
|
||||
|
||||
@Test fun dropsMalformedJson() {
|
||||
assertNull(client.parseFrame("""{"kind":"x" """))
|
||||
assertNull(client.parseFrame("not json"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding tests for the shard DTOs (PLAN.md §6.2). Shapes come from the website's
|
||||
* `public/shard.controller.js`; the parser must ignore unknown keys (additive
|
||||
* backend fields, §8) and decode the live `*.update` SSE frames — which carry a
|
||||
* `kind` and category-specific extras — into the same board DTOs.
|
||||
*/
|
||||
class ShardDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun statusDecodesAndDerivesOnline() {
|
||||
val dto = json.decodeFromString<ShardStatusDto>(
|
||||
"""{"enabled":true,"status":"connected","pluginConnected":true,
|
||||
"lastEventAt":"2026-07-19T22:00:00Z","onlineCount":42,
|
||||
"economy":{"accounts":900,"gold":123456789.0,"t":1721426400000}}""",
|
||||
)
|
||||
assertTrue(dto.isOnline)
|
||||
assertEquals(42, dto.onlineCount)
|
||||
assertEquals(123456789.0, dto.economy?.gold!!, 0.0)
|
||||
}
|
||||
|
||||
@Test fun statusOfflineWhenPluginDisconnected() {
|
||||
val dto = json.decodeFromString<ShardStatusDto>(
|
||||
"""{"enabled":true,"status":"disconnected","pluginConnected":false,"onlineCount":0}""",
|
||||
)
|
||||
assertFalse(dto.isOnline)
|
||||
}
|
||||
|
||||
@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.
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"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}""",
|
||||
)
|
||||
assertEquals(12345L, dto.serial)
|
||||
assertEquals("champion", dto.category)
|
||||
assertEquals(120, dto.kills)
|
||||
assertTrue(dto.active)
|
||||
}
|
||||
|
||||
@Test fun guildFrameDecodesLeaderActor() {
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
|
||||
"online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""",
|
||||
)
|
||||
assertEquals(7L, dto.id)
|
||||
assertEquals("Arthur", dto.leader?.label)
|
||||
assertEquals(12, dto.members)
|
||||
}
|
||||
|
||||
@Test fun governorFrameDecodesNullGovernor() {
|
||||
val dto = json.decodeFromString<GovernorDto>(
|
||||
"""{"kind":"city.update","city":"Britain","governor":null,"electionPhase":"nominations"}""",
|
||||
)
|
||||
assertEquals("Britain", dto.city)
|
||||
assertNull(dto.governor)
|
||||
assertEquals("nominations", dto.electionPhase)
|
||||
}
|
||||
|
||||
@Test fun presenceDecodesMaps() {
|
||||
val dto = json.decodeFromString<PresenceDto>(
|
||||
"""{"count":37,"byFacet":{"Felucca":10,"Trammel":27},"byRegion":{"Britain":5},"t":1}""",
|
||||
)
|
||||
assertEquals(37, dto.count)
|
||||
assertEquals(27, dto.byFacet["Trammel"])
|
||||
assertEquals(5, dto.byRegion["Britain"])
|
||||
}
|
||||
|
||||
@Test fun houseDecodesPublicIdocShape() {
|
||||
val dto = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":999,"name":"Tower","region":"Britain","map":"Felucca",
|
||||
"x":1,"y":2,"z":3,"isIdoc":true}""",
|
||||
)
|
||||
assertEquals(999L, dto.serial)
|
||||
assertTrue(dto.isIdoc)
|
||||
}
|
||||
|
||||
@Test fun actorLabelFallsBackToAcctThenSomeone() {
|
||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||
assertEquals("Someone", ActorDto().label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Unit tests for the live board merge helper (PLAN.md §6.2). */
|
||||
class LiveBoardTest {
|
||||
|
||||
private data class Row(val id: String, val name: String)
|
||||
|
||||
private fun board() = LiveBoard<Row> { it.id }
|
||||
|
||||
@Test fun seedReplacesContents() {
|
||||
val b = board()
|
||||
b.seed(listOf(Row("1", "a"), Row("2", "b")))
|
||||
b.seed(listOf(Row("3", "c")))
|
||||
assertEquals(listOf(Row("3", "c")), b.values())
|
||||
}
|
||||
|
||||
@Test fun upsertInsertsThenUpdatesInPlace() {
|
||||
val b = board()
|
||||
b.seed(listOf(Row("1", "a")))
|
||||
b.upsert(Row("2", "b"))
|
||||
b.upsert(Row("1", "a2")) // update existing — no reorder, no duplicate
|
||||
assertEquals(listOf(Row("1", "a2"), Row("2", "b")), b.values())
|
||||
}
|
||||
|
||||
@Test fun removeDropsById() {
|
||||
val b = board()
|
||||
b.seed(listOf(Row("1", "a"), Row("2", "b")))
|
||||
b.remove("1")
|
||||
assertEquals(listOf(Row("2", "b")), b.values())
|
||||
}
|
||||
|
||||
@Test fun removeMissingIsNoOp() {
|
||||
val b = board()
|
||||
b.seed(listOf(Row("1", "a")))
|
||||
b.remove("nope")
|
||||
assertEquals(listOf(Row("1", "a")), b.values())
|
||||
}
|
||||
|
||||
@Test fun insertionOrderPreserved() {
|
||||
val b = board()
|
||||
b.upsert(Row("b", "1"))
|
||||
b.upsert(Row("a", "2"))
|
||||
b.upsert(Row("c", "3"))
|
||||
assertEquals(listOf("b", "a", "c"), b.values().map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the live-feed event descriptions (PLAN.md §6.2), mirroring the
|
||||
* website's `lib/shardEvents.js`. Fields arrive as a [kotlinx.serialization.json.JsonObject]
|
||||
* — the shape of a live SSE frame.
|
||||
*/
|
||||
class ShardEventTextTest {
|
||||
|
||||
private fun fields(literal: String) = Json.parseToJsonElement(literal).jsonObject
|
||||
|
||||
@Test fun playerDeathWithKiller() {
|
||||
val text = ShardEventText.describe(
|
||||
"player.death",
|
||||
fields("""{"who":{"name":"Alice"},"killer":{"name":"a dragon"}}"""),
|
||||
)
|
||||
assertEquals("Alice was slain by a dragon", text)
|
||||
}
|
||||
|
||||
@Test fun playerDeathWithoutKiller() {
|
||||
val text = ShardEventText.describe("player.death", fields("""{"who":"Bob"}"""))
|
||||
assertEquals("Bob was slain", text)
|
||||
}
|
||||
|
||||
@Test fun economySupplyGroupsNumbers() {
|
||||
val text = ShardEventText.describe(
|
||||
"economy.supply",
|
||||
fields("""{"gold":123456789,"accounts":1200}"""),
|
||||
)
|
||||
assertEquals("Gold supply: 123,456,789 across 1,200 accounts", text)
|
||||
}
|
||||
|
||||
@Test fun houseDecayReadsToAndRegion() {
|
||||
val text = ShardEventText.describe(
|
||||
"house.decay",
|
||||
fields("""{"name":"Keep","to":"IDOC","region":"Britain"}"""),
|
||||
)
|
||||
assertEquals("Keep is now IDOC — Britain", text)
|
||||
}
|
||||
|
||||
@Test fun champUpdateActiveWithBoss() {
|
||||
val text = ShardEventText.describe(
|
||||
"champ.update",
|
||||
fields("""{"name":"Rikktor","status":"active","bossUp":true,"boss":"Rikktor"}"""),
|
||||
)
|
||||
assertEquals("Rikktor: boss is up (Rikktor)", text)
|
||||
}
|
||||
|
||||
@Test fun cityUpdateWithGovernor() {
|
||||
val text = ShardEventText.describe(
|
||||
"city.update",
|
||||
fields("""{"city":"Trinsic","governor":{"name":"Dupre"}}"""),
|
||||
)
|
||||
assertEquals("Trinsic is governed by Dupre", text)
|
||||
}
|
||||
|
||||
@Test fun presenceOnlineCount() {
|
||||
assertEquals(
|
||||
"58 players online",
|
||||
ShardEventText.describe("presence.online", fields("""{"count":58}""")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun unknownKindFallsBackToKind() {
|
||||
assertEquals("some.weird.kind", ShardEventText.describe("some.weird.kind", fields("{}")))
|
||||
}
|
||||
|
||||
@Test fun missingActorReadsAsSomeone() {
|
||||
val text = ShardEventText.describe("mob.login", fields("{}"))
|
||||
assertTrue(text.startsWith("Someone"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user