Merge pull request 'feat(m2): public shard widgets + live SSE stream' (#7) from feat/m2-public-shard into main

Reviewed-on: #7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-19 23:44:37 +00:00
26 changed files with 2192 additions and 3 deletions

View File

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

View File

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

View File

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

View 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,
)

View File

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

View File

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

View File

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

View 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 -> ""
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()
}

View File

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

View File

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

View 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),
)
}

View File

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

View File

@@ -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 &amp; 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>

View File

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

View File

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

View File

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

View File

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