diff --git a/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt b/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt new file mode 100644 index 0000000..1a5893d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt @@ -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 = 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() + 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? { + 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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/ShardStreamEvent.kt b/app/src/main/java/com/runicgateway/app/core/net/ShardStreamEvent.kt new file mode 100644 index 0000000..c79cdbf --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/ShardStreamEvent.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt index 66ef64a..462413c 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt @@ -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 + + @GET("api/v1/public/shard/economy") + suspend fun getShardEconomy(@Query("limit") limit: Int? = null): List + + @GET("api/v1/public/shard/online") + suspend fun getShardOnline(): List + + @GET("api/v1/public/shard/presence") + suspend fun getShardPresence(): PresenceDto + + @GET("api/v1/public/shard/champs") + suspend fun getShardChamps(): List + + @GET("api/v1/public/shard/guilds") + suspend fun getShardGuilds(): List + + @GET("api/v1/public/shard/governors") + suspend fun getShardGovernors(): List + + @GET("api/v1/public/shard/governors/{city}/history") + suspend fun getShardGovernorHistory( + @Path("city") city: String, + @Query("limit") limit: Int? = null, + ): List + + @GET("api/v1/public/shard/houses") + suspend fun getShardHouses(): List } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt new file mode 100644 index 0000000..e18f688 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt @@ -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 = emptyMap(), + val byRegion: Map = emptyMap(), + val t: Long? = null, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt new file mode 100644 index 0000000..81e0739 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt @@ -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 = safeApiCall { api.getShardStatus() } + + suspend fun feed(limit: Int = 40): ApiResult> = + safeApiCall { api.getShardFeed(limit = limit) } + + suspend fun economy(limit: Int = 100): ApiResult> = + safeApiCall { api.getShardEconomy(limit) } + + suspend fun online(): ApiResult> = safeApiCall { api.getShardOnline() } + + suspend fun presence(): ApiResult = safeApiCall { api.getShardPresence() } + + suspend fun champs(): ApiResult> = safeApiCall { api.getShardChamps() } + + suspend fun guilds(): ApiResult> = safeApiCall { api.getShardGuilds() } + + suspend fun governors(): ApiResult> = safeApiCall { api.getShardGovernors() } + + suspend fun governorHistory(city: String, limit: Int = 25): ApiResult> = + safeApiCall { api.getShardGovernorHistory(city, limit) } + + suspend fun houses(): ApiResult> = safeApiCall { api.getShardHouses() } + + // ── Live stream ────────────────────────────────────────────────────── + /** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */ + fun liveEvents(): Flow = 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 decode(obj: JsonObject, serializer: KSerializer): T? = try { + json.decodeFromJsonElement(serializer, obj) + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 1a8bd21..a61079b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -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)) }) } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 4a42bb4..e0969c3 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -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}" diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt new file mode 100644 index 0000000..ebcf149 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt @@ -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 -> "" + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt new file mode 100644 index 0000000..fe86e3a --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt @@ -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 { it.serial.toString() } + + private val _state = MutableStateFlow>>(UiState.Loading) + val state: StateFlow>> = _state.asStateFlow() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/FrameFields.kt b/app/src/main/java/com/runicgateway/app/ui/shard/FrameFields.kt new file mode 100644 index 0000000..333d781 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/FrameFields.kt @@ -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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt new file mode 100644 index 0000000..2d33b91 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt @@ -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?, + 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), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsViewModel.kt new file mode 100644 index 0000000..f218862 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsViewModel.kt @@ -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 { it.city } + + private val _state = MutableStateFlow>>(UiState.Loading) + val state: StateFlow>> = _state.asStateFlow() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _connected.asStateFlow() + + /** Per-city expanded term history, loaded lazily; absent = not yet requested. */ + private val _history = MutableStateFlow>>(emptyMap()) + val history: StateFlow>> = _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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt new file mode 100644 index 0000000..9df373c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsViewModel.kt new file mode 100644 index 0000000..dfae005 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsViewModel.kt @@ -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 { it.id.toString() } + + private val _state = MutableStateFlow>>(UiState.Loading) + val state: StateFlow>> = _state.asStateFlow() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt new file mode 100644 index 0000000..6b1f40f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt new file mode 100644 index 0000000..e3b7d2c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt @@ -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 { it.serial.toString() } + + private val _state = MutableStateFlow>>(UiState.Loading) + val state: StateFlow>> = _state.asStateFlow() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt b/app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt new file mode 100644 index 0000000..5f1b70b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt @@ -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(private val idOf: (T) -> String) { + private val items = LinkedHashMap() + + /** Replace the whole board with a fresh snapshot. */ + fun seed(snapshot: List) { + 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 = items.values.toList() +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ShardComponents.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ShardComponents.kt new file mode 100644 index 0000000..81b2cdb --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardComponents.kt @@ -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 LiveBoardScreen( + emptyMessage: String, + state: UiState>, + 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) } + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ShardEventText.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ShardEventText.kt new file mode 100644 index 0000000..73954d7 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardEventText.kt @@ -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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt new file mode 100644 index 0000000..3274665 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt @@ -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, + 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), + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ShardViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ShardViewModel.kt new file mode 100644 index 0000000..4b8e1fe --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardViewModel.kt @@ -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, +) + +/** + * 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.Loading) + val state: StateFlow> = _state.asStateFlow() + + private val _feed = MutableStateFlow>(emptyList()) + val feed: StateFlow> = _feed.asStateFlow() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _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, 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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f6762f..25b6055 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ Home News Wiki + Shard About Contact Change server @@ -73,4 +74,46 @@ This site has no mailer configured. Email directly: %1$s Please fill in every field. Couldn\'t send your message. Please try again. + + + Live + Offline + Shard online + Shard offline + %1$d players online + Gold supply: %1$s + Staff online + Live activity + No recent activity. + Champion spawns + Guilds + Governors + Falling houses + + + No champion spawns are being tracked right now. + Champion spawn + Level %1$d + Restarting + + + No guilds are being tracked right now. + Guild + %1$d members + Led by %1$s + Alliance: %1$s + + + No governors — this shard may not run the City Loyalty system. + Governed by %1$s + No governor + Election: %1$s + Term history + Loading… + No past terms recorded. + + + No houses are in danger right now. + A house + IDOC diff --git a/app/src/test/java/com/runicgateway/app/core/net/ShardStreamClientTest.kt b/app/src/test/java/com/runicgateway/app/core/net/ShardStreamClientTest.kt new file mode 100644 index 0000000..cff14a8 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/net/ShardStreamClientTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt new file mode 100644 index 0000000..dc27c78 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt @@ -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( + """{"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( + """{"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( + """{"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( + """{"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( + """{"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( + """{"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( + """{"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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/LiveBoardTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/LiveBoardTest.kt new file mode 100644 index 0000000..fa3e23a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/shard/LiveBoardTest.kt @@ -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 { 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 }) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/ShardEventTextTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/ShardEventTextTest.kt new file mode 100644 index 0000000..f51997d --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/shard/ShardEventTextTest.kt @@ -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")) + } +}