From 833e51de698bff8912b68b10f7b80a78c8f4bbfd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 30 Jul 2026 02:34:41 -0500 Subject: [PATCH 1/3] feat(shard): follow the visibility framework and read the Protocol 3.0 profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M11 Part 1 (docs/android/PLAN.md §9). The website's Protocol 3.0 work made every shard-derived surface admin-configurable — a feature can be switched off, or its audience raised above the caller's rung — and the app knew nothing about it: it gated shard navigation on the session role alone, so an admin change left the drawer and the hub offering entries that 404/403 into a generic error where the web client hides them. The visibility rules: - GET /public/shard/features behind a singleton ShardFeaturesRepository, re-resolved on every session change (the answer is per-viewer) and dropped on a Settings → Server switch, which is the one case no session change covers. - MenuEntry gains `feature` beside `access`; the two gates are independent and both must pass. ShardBoard tags each hub tile the same way. - An unknown answer FAILS OPEN, matching lib/useShardFeatures.js: the server gates every call regardless, so a link that briefly 403s beats a drawer that flickers its entries in on every cold start. A pre-3.0 website 404s this route, which reads as "unknown" and behaves exactly as before. - toShardUiState() maps 404 AND 403 to a new ErrorKind.FEATURE_UNAVAILABLE: requireFeature answers 404 for a disabled feature (deliberately not disclosing it exists) and 403 for a viewer below its rung. Kept separate from toUiState() because both statuses mean something else off the shard surface — a deleted post, an ownership refusal. That state renders without a retry button; an admin controls it, so retrying cannot change the answer. The read-model adds, from the same v3 series: - char.profile `points` — the Loyalty & Points block. maxPoints 0 means UNCAPPED and is the common case, so nothing divides by it and only a capped system gets a meter; nameString is usually null (systems name themselves with a cliloc) so humanising the PointsType key is the primary display path; rank is absent unless the shard opts in, and absent is not "unranked". - Cliloc-resolved names — equipment `clilocName` and titles `rewardResolved`, so items stop rendering as a layer. rewardResolved is positional: an entry the table could not resolve is null and is skipped WITHOUT shifting the `selected` index onto its neighbour. ActorDto keeps acct/webId but documents them as admin-locked rather than available. Points ride ungated on /player/shard/char/:serial — a character's own standings are self-service and do not depend on the public leaderboards feature, so the app mirrors that rather than re-gating it. 304 unit tests pass; lint clean. Co-Authored-By: Claude --- .../runicgateway/app/data/api/PublicApi.kt | 9 ++ .../app/data/api/dto/PlayerShardDto.kt | 75 +++++++++++- .../runicgateway/app/data/api/dto/ShardDto.kt | 29 ++++- .../data/repository/ConnectionRepository.kt | 5 + .../repository/ShardFeaturesRepository.kt | 111 ++++++++++++++++++ .../java/com/runicgateway/app/ui/RunicApp.kt | 4 +- .../java/com/runicgateway/app/ui/UiState.kt | 31 +++++ .../app/ui/components/StateViews.kt | 20 +++- .../runicgateway/app/ui/navigation/Menu.kt | 34 +++++- .../app/ui/player/CharacterSheetScreen.kt | 96 +++++++++++++-- .../app/ui/session/SessionViewModel.kt | 20 ++++ .../app/ui/shard/ChampsViewModel.kt | 4 +- .../app/ui/shard/GovernorsViewModel.kt | 4 +- .../app/ui/shard/GuildsViewModel.kt | 4 +- .../app/ui/shard/HousesViewModel.kt | 4 +- .../runicgateway/app/ui/shard/ShardScreen.kt | 47 ++++++-- .../app/ui/shard/ShardViewModel.kt | 15 ++- app/src/main/res/values/strings.xml | 6 + .../app/data/api/dto/PlayerShardDtoTest.kt | 54 +++++++++ .../app/data/api/dto/ShardDtoTest.kt | 26 ++++ .../app/data/api/fake/FakePublicApi.kt | 3 + .../repository/ShardFeaturesRepositoryTest.kt | 104 ++++++++++++++++ .../com/runicgateway/app/ui/UiStateTest.kt | 40 +++++++ .../ui/navigation/MenuFeatureGatingTest.kt | 108 +++++++++++++++++ .../ui/player/CharacterSheetHelpersTest.kt | 104 +++++++++++++++- .../app/ui/shard/ShardBoardViewModelTest.kt | 9 +- 26 files changed, 915 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/ShardFeaturesRepository.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/repository/ShardFeaturesRepositoryTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/navigation/MenuFeatureGatingTest.kt 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 462413c..850def6 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 @@ -17,6 +17,7 @@ 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.ShardFeaturesDto import com.runicgateway.app.data.api.dto.ShardStatusDto import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.api.dto.WikiCategoryDto @@ -93,6 +94,14 @@ interface PublicApi { suspend fun postContact(@Body body: ContactRequest): ContactResponse // ── Public shard widgets (§6.2) ────────────────────────────────────── + /** + * Which shard features this caller may reach, so the menu hides entries instead + * of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous + * call and a signed-in one can differ. + */ + @GET("api/v1/public/shard/features") + suspend fun getShardFeatures(): ShardFeaturesDto + @GET("api/v1/public/shard/status") suspend fun getShardStatus(): ShardStatusDto diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt index cece9e4..b26b858 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt @@ -83,8 +83,47 @@ data class CharProfileDto( val titles: TitlesDto? = null, val guild: GuildRefDto? = null, val governorOf: List = emptyList(), + /** + * Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has + * earned nothing anywhere — the shard omits systems the character has no entry in + * — and empty on a shard whose plugin predates 3.0. + * + * Served **ungated**: a character's own standings are self-service data on + * `/player/shard/char/:serial` and do not depend on the public `leaderboards` + * feature being visible. Don't re-gate them app-side. + */ + val points: List = emptyList(), ) +/** + * One point system a character holds a score in (Protocol 3.0 §7.3). + * + * Three shapes here are counter-intuitive, and all three are what a REAL shard sends + * (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should): + * + * - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case. + * ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin + * normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`. + * Nothing may divide by it, and a full-width progress bar for an uncapped score + * would imply a completion that doesn't exist. + * - **[nameString] is usually `null`.** Most systems name themselves with a cliloc + * rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens + * Loyalty") is the PRIMARY display path, not a defensive fallback. + * - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.** + * Absent and "unranked" are different answers, so it renders only when sent. + */ +@Serializable +data class CharPointsDto( + val system: String? = null, + val nameString: String? = null, + val points: Long? = null, + val maxPoints: Long? = null, + val rank: Int? = null, +) { + /** The cap, or null when the system is uncapped (see [maxPoints]). */ + val cap: Long? get() = maxPoints?.takeIf { it > 0 } +} + @Serializable data class CharStatsDto( val str: Int? = null, @@ -134,17 +173,45 @@ data class EquipmentDto( val itemId: Int? = null, val hue: Int? = null, val mods: JsonObject? = null, -) + /** + * A player-given name — set for the minority of items someone has renamed, null + * for almost everything else. The shard sends the plain `Item.Name` field; it + * never builds a display name (that call is a packet builder, not a field read). + */ + val name: String? = null, + /** + * The item's type name, resolved from its cliloc id **by the website** against + * its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc + * table configured, which is fully supported — the sheet then falls back to the + * layer, exactly as it did before the table existed. + */ + val clilocName: String? = null, +) { + /** + * What to call this item. + * + * A player-given [name] outranks the resolved type name — "Bob's lucky axe" must + * not be relabelled "hatchet" — and the server applies the same precedence, so + * this only re-states it for an item that arrived with both. + */ + val label: String? get() = name ?: clilocName ?: layer +} /** - * Display titles (Protocol 2.0). `selected` is the index into `reward` currently - * shown (-1 if none); `reward` entries may be a cliloc number-as-string or a - * literal — numeric ones are skipped without a cliloc table (as the website does). + * Display titles (Protocol 2.0). `selected` is the index into [reward] currently + * shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal. + * + * [rewardResolved] is the website's **parallel array** with the numeric entries turned + * into words against its cliloc table — same length and order as [reward], with a null + * where an id resolved to nothing. It is absent entirely when no entry was numeric or + * the shard has no cliloc table, so read it positionally and tolerate it being short. + * See `displayTitles` in the character sheet. */ @Serializable data class TitlesDto( val selected: Int? = null, val reward: List = emptyList(), + val rewardResolved: List = emptyList(), val fameKarma: String? = null, val skill: String? = null, ) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt index f748b69..63686f7 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt @@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject * `*.update` frames on `/public/shard/stream` decode into these same DTOs. */ +/** + * Which shard surfaces this caller may reach (`GET /public/shard/features`), plus + * the audience rung they resolved to. + * + * Every shard-derived feature is admin-configurable — it can be switched off or + * raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11). + * [level] is the SERVER's answer on the `anonymous → logged_in → player → staff → + * admin` ladder and is authoritative: don't re-derive a rung from the session role, + * since `player` means *a linked game account* and staff always satisfy it. + * + * The response reports only what the caller can see, so the list itself never + * discloses a feature they're gated out of. + */ +@Serializable +data class ShardFeaturesDto( + val level: String? = null, + val features: List = emptyList(), +) + /** * A game actor (player/leader/governor) as embedded in board payloads. Per the wire * spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys - * (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a - * string (e.g. `"9931"`) — both are decoded as strings, not parsed. + * (e.g. `"0x1A2B"`), never numbers. + * + * [acct] and [webId] are **locked to the admin rung** by the visibility framework + * (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are + * not in-game-visible the way a character name is, so they are stripped from every + * response below `admin` and no admin setting can loosen that. The fields stay + * declared because an admin session does receive them; nothing below one should + * expect a value. */ @Serializable data class ActorDto( diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt index f3ebd0a..104c49d 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -28,6 +28,7 @@ class ConnectionRepository @Inject constructor( private val baseUrlHolder: BaseUrlHolder, private val sessionManager: SessionManager, private val trustTokenStore: TrustTokenStore, + private val shardFeaturesRepository: ShardFeaturesRepository, private val pushManager: com.runicgateway.app.core.push.PushManager, private val config: com.runicgateway.app.core.AppConfig, ) { @@ -111,6 +112,10 @@ class ConnectionRepository @Inject constructor( // The trust token is bound to the old host — drop it so we don't replay it // against a different shard (it survives a plain logout, but not a host switch). trustTokenStore.clear() + // Shard visibility is the OLD host's answer. Sign-out alone would not clear it: + // a switch between two signed-out hosts changes no session, so nothing else + // invalidates the cache and the new shard would inherit the old one's menu. + shardFeaturesRepository.invalidate() prefs.clear() baseUrlHolder.set(null) } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ShardFeaturesRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ShardFeaturesRepository.kt new file mode 100644 index 0000000..b440bc2 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/ShardFeaturesRepository.kt @@ -0,0 +1,111 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.safeApiCall +import com.runicgateway.app.data.api.PublicApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Which shard surfaces the current viewer may reach, from + * `GET /public/shard/features` (PLAN.md §5, §9 M11). + * + * Every shard-derived feature is admin-configurable — it can be switched off, or its + * audience raised above the caller's rung — so shard navigation can no longer be a + * static list gated on the session role alone. [level] is the server's own answer on + * the `anonymous → logged_in → player → staff → admin` ladder; the app does not + * re-derive it. + * + * **This is presentation only.** The gate is server-side: a disabled feature `404`s + * and an out-of-rung one `403`s whether or not the entry was rendered. That is why an + * unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee]. + */ +@Singleton +class ShardFeaturesRepository @Inject constructor( + private val api: PublicApi, +) { + private val _features = MutableStateFlow(null) + + /** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */ + val features: StateFlow = _features.asStateFlow() + + // Serializes concurrent refreshes: the shell refreshes on every session change, + // and two overlapping loads would race to publish. + private val mutex = Mutex() + + /** + * Re-resolve the visible set. Called on every session change (sign-in, sign-out, + * a role revalidation that actually changed the user), because the answer is + * per-viewer. + * + * A failed lookup clears the cache rather than keeping a stale one: falling back + * to "show everything" is the safe direction here, since the server still gates + * every call. + */ + suspend fun refresh() = mutex.withLock { + _features.value = when (val result = safeApiCall { api.getShardFeatures() }) { + is ApiResult.Ok -> ShardFeatures( + level = result.data.level, + visible = result.data.features.toSet(), + ) + // Includes the 404 an older, pre-Protocol-3.0 website returns for this + // route — that site has no visibility framework, so "unknown" is exactly + // the right answer and the menu behaves as it did before M11. + else -> null + } + } + + /** + * Drop the cached answer. Called on a Settings → Server switch: the features + * belong to the host that reported them, and a switch between two signed-out + * hosts changes no session, so nothing else would invalidate them. + */ + fun invalidate() { + _features.value = null + } +} + +/** + * The resolved visibility answer for one viewer: the rung the server placed them on + * and the shard features they may reach. + */ +data class ShardFeatures( + val level: String?, + val visible: Set, +) + +/** + * True when [feature] may be shown — **or when the answer isn't known yet**. + * + * The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`): + * the server gates every call regardless, so the cost of guessing wrong is a link that + * briefly `403`s, while the cost of guessing the other way is a navigation drawer that + * flickers its entries in on every cold start. + */ +fun canSee(features: ShardFeatures?, feature: String): Boolean = + features == null || feature in features.visible + +/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */ +object ShardFeature { + const val STATUS = "status" + const val ACTIVITY = "activity" + const val CHAMPS = "champs" + const val GUILDS = "guilds" + const val GOVERNORS = "governors" + const val HOUSES = "houses" + const val PRESENCE = "presence" + + // Added by Protocol 3.0. + const val RULESET = "ruleset" + const val ATLAS = "atlas" + const val LEADERBOARDS = "leaderboards" + const val MARKET = "market" +} 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 3f4db7c..3f55d48 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -112,6 +112,8 @@ fun RunicApp( val scope = rememberCoroutineScope() val session by sessionViewModel.session.collectAsStateWithLifecycle() + // What this shard publishes, independently of who the caller is (§5, M11). + val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle() // Re-validate the cached role each time the app returns to the foreground (§4.3). LifecycleResumeEffect(Unit) { @@ -132,7 +134,7 @@ fun RunicApp( val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route val isTopLevel = currentRoute in TOP_LEVEL_ROUTES - val entries = visibleEntries(APP_MENU, session) + val entries = visibleEntries(APP_MENU, session, shardFeatures) ModalNavigationDrawer( drawerState = drawerState, diff --git a/app/src/main/java/com/runicgateway/app/ui/UiState.kt b/app/src/main/java/com/runicgateway/app/ui/UiState.kt index abe0826..c0efa1a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/UiState.kt +++ b/app/src/main/java/com/runicgateway/app/ui/UiState.kt @@ -34,6 +34,13 @@ enum class ErrorKind { /** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */ SHARD_OFFLINE, + /** + * This shard doesn't publish the surface, or doesn't publish it to this viewer + * (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard + * may well be up, and retrying changes nothing — an admin decides this. + */ + FEATURE_UNAVAILABLE, + /** Any other non-2xx server response. */ SERVER, } @@ -52,3 +59,27 @@ fun ApiResult.toUiState(): UiState = when (this) { httpStatus = status, ) } + +/** + * [toUiState] for a **shard-derived** read, where `404` carries a second meaning. + * + * The website's `requireFeature` gate answers `404` when a feature is switched off — + * deliberately, so the response doesn't disclose that the surface exists — and `403` + * when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6). + * On these routes a `404` therefore almost never means "no such thing"; it means this + * shard doesn't publish it. Rendering "couldn't be found" with a retry button would + * invite the user to retry something an admin controls. + * + * Kept as a separate mapper rather than folded into [toUiState] because both statuses + * mean something else off the shard surface: `404` is a genuinely missing item (a + * deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a + * player or admin route, which is not an admin's visibility setting. + */ +fun ApiResult.toShardUiState(): UiState = when (this) { + is ApiResult.HttpError -> if (status == 403 || status == 404) { + UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status) + } else { + toUiState() + } + else -> toUiState() +} diff --git a/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt index 6acdf1f..eedb479 100644 --- a/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt +++ b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt @@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) { /** * Whole-screen error state with a friendly, kind-specific message and a Retry * button (§7). Copy is resolved from string resources so it stays localizable. + * + * [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides + * whether the shard publishes that surface, so retrying cannot change the answer and + * offering it would read as a transient failure the user could wait out (M11). */ @Composable fun ErrorView( @@ -53,15 +57,20 @@ fun ErrorView( style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, ) - Button( - onClick = onRetry, - modifier = Modifier.padding(top = 16.dp).width(160.dp), - ) { - Text(stringResource(R.string.action_retry)) + if (isRetryable(kind)) { + Button( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp).width(160.dp), + ) { + Text(stringResource(R.string.action_retry)) + } } } } +/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */ +fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE + /** Centered informational message for an empty list (§7). */ @Composable fun EmptyView(message: String, modifier: Modifier = Modifier) { @@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) { ErrorKind.NOT_FOUND -> R.string.error_not_found ErrorKind.RATE_LIMITED -> R.string.error_rate_limited ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline + ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable ErrorKind.SERVER -> R.string.error_server } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 32ab76c..abe96c8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation import androidx.annotation.StringRes import com.runicgateway.app.R import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import com.runicgateway.app.data.repository.canSee /** * One shared, declarative, access-level navigation definition (PLAN.md §5): a @@ -40,6 +43,15 @@ data class MenuEntry( val route: String, @param:StringRes val labelRes: Int, val access: MenuAccess = MenuAccess.PUBLIC, + /** + * For a shard-derived surface, the visibility feature it belongs to (M11). + * + * Session role is not the only gate on these: an admin can switch a feature off + * or raise its audience above the caller's rung, so the entry is filtered by + * `GET /public/shard/features` as well as by [access]. `null` means the entry + * isn't shard-derived and only [access] applies. + */ + val feature: String? = null, ) /** @@ -51,7 +63,7 @@ val APP_MENU: List = 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.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS), MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), @@ -67,16 +79,28 @@ val APP_MENU: List = listOf( ) /** - * The entries the given [session] may see. Pure + side-effect-free so the access - * gating is unit-tested without Compose. + * The entries the given [session] may see, given the shard [features] it may reach. + * Pure + side-effect-free so the gating is unit-tested without Compose. + * + * Two independent filters, and both must pass: + * + * - [MenuEntry.access] against the session — who the caller is. + * - [MenuEntry.feature] against the shard's live visibility config — what this shard + * publishes at all (M11). `null` [features] means the answer isn't known yet and + * every shard entry shows; see [canSee] for why that direction is deliberate. */ -fun visibleEntries(entries: List, session: Session): List = +fun visibleEntries( + entries: List, + session: Session, + features: ShardFeatures? = null, +): List = entries.filter { entry -> - when (entry.access) { + val allowedByRole = when (entry.access) { MenuAccess.PUBLIC -> true MenuAccess.SIGNED_IN -> session is Session.SignedIn MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff) MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator } + allowedByRole && (entry.feature == null || canSee(features, entry.feature)) } diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt index 22998fc..790835e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt @@ -26,6 +26,7 @@ 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.CharPointsDto import com.runicgateway.app.data.api.dto.CharProfileDto import com.runicgateway.app.data.api.dto.CharStatsDto import com.runicgateway.app.data.api.dto.EquipmentDto @@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier) char.stats?.let { AttributesBlock(it) } char.stats?.resist?.let { ResistancesBlock(it) } SkillsBlock(char.skills) + PointsBlock(displayPoints(char)) EquipmentBlock(char.equipment) } } @@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List) { } } +/** + * Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a + * character that has earned nothing anywhere, which is a normal state. + * + * Only a system with a real cap gets a meter: an uncapped score + * ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be + * a fraction of, and a full-width bar would imply a completion that doesn't exist. + */ +@Composable +private fun PointsBlock(points: List) { + if (points.isEmpty()) return + SheetCard(R.string.player_char_points) { + points.forEach { entry -> + val cap = entry.cap + val score = entry.points ?: 0L + Column(Modifier.padding(vertical = 5.dp)) { + Row( + Modifier.fillMaxWidth().padding(bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + // `rank` is absent unless the shard opts in; absent and + // "unranked" are different, so the suffix only appears when sent. + entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) } + ?: pointsLabel(entry), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + } + if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat()) + } + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun EquipmentBlock(equipment: List) { @@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List) { equipment.forEach { item -> Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Text( - item.layer ?: stringResource(R.string.player_char_item), + item.label ?: stringResource(R.string.player_char_item), style = MaterialTheme.typography.bodyLarge, ) val meta = listOfNotNull( @@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String = /** * The human-readable title chips for a [TitlesDto] (parity with the website's * `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected - * reward title — but only if it is a literal string, not a bare cliloc number - * (the app ships no cliloc table). De-duplicated, blanks dropped. + * reward title. + * + * Reward entries arrive as either a literal or a cliloc number in string form. The + * server now resolves the numeric ones into `rewardResolved`, a **parallel** array + * (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry + * that didn't resolve becomes null and is skipped, but must not shift the `selected` + * index onto its neighbour. A number with no resolution is still skipped rather than + * rendered as a raw id, which is also the whole behavior on a shard that configures + * no cliloc table. + * + * Falling back to the first title that resolved (rather than showing nothing) matters + * when the *selected* one is the unresolved entry. De-duplicated, blanks dropped. */ internal fun displayTitles(titles: TitlesDto?): List { if (titles == null) return emptyList() val out = mutableListOf() titles.fameKarma?.let { out.add(it) } titles.skill?.let { out.add(it) } - val reward = titles.reward - val sel = titles.selected ?: -1 - val candidate = when { - sel in reward.indices -> reward[sel] - else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) } + val reward = titles.reward.mapIndexed { i, raw -> + titles.rewardResolved.getOrNull(i) + ?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) } } - if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate) + val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it } + if (!candidate.isNullOrBlank()) out.add(candidate) return out.filter { it.isNotBlank() }.distinct() } +/** + * A point system's display name: the shard's own [CharPointsDto.nameString] when it + * has one, else the humanised `PointsType` key. + * + * The fallback is the PRIMARY path, not a defensive nicety — most systems name + * themselves with a cliloc, so `nameString` comes back null for four of five boards + * on a real shard (`docs/link/v3.md` §7.5). Parity with the website's + * `humanisePoints`. + */ +internal fun pointsLabel(entry: CharPointsDto): String { + entry.nameString?.takeIf { it.isNotBlank() }?.let { return it } + val key = entry.system.orEmpty() + return key + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replaceFirstChar { it.uppercaseChar() } +} + +/** + * The points block, best standing first, dropping systems the character has no score + * in. Guarded for an older shard plugin that sends no `points` block at all. + */ +internal fun displayPoints(char: CharProfileDto): List = + char.points + .filter { (it.points ?: 0L) > 0L } + .sortedByDescending { it.points ?: 0L } + private fun jsonText(element: kotlinx.serialization.json.JsonElement): String = runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() } diff --git a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt index e855656..4b10175 100644 --- a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt @@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope import com.runicgateway.app.core.auth.Session import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.data.repository.AuthRepository +import com.runicgateway.app.data.repository.ShardFeatures +import com.runicgateway.app.data.repository.ShardFeaturesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -23,10 +25,28 @@ import javax.inject.Inject class SessionViewModel @Inject constructor( sessionManager: SessionManager, private val authRepository: AuthRepository, + shardFeaturesRepository: ShardFeaturesRepository, ) : ViewModel() { val session: StateFlow = sessionManager.state + /** + * Which shard features this viewer may reach (M11). Held here beside [session] + * because it answers the same question for the same consumer: what the shared + * menu reveals. Role and feature config are independent gates — see + * [com.runicgateway.app.ui.navigation.visibleEntries]. + */ + val shardFeatures: StateFlow = shardFeaturesRepository.features + + init { + // The answer is per-viewer, so it is re-resolved on every session change. + // A StateFlow conflates equal values, so a resume revalidation that returns + // the same user does not refetch — only a real sign-in/out/role change does. + viewModelScope.launch { + session.collect { shardFeaturesRepository.refresh() } + } + } + /** Re-validate the cached role against the backend on app resume. */ fun revalidate() { viewModelScope.launch { authRepository.revalidate() } diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt index 20ea6c6..54e07ff 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsViewModel.kt @@ -10,7 +10,7 @@ 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 com.runicgateway.app.ui.toShardUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor( board.seed(result.data) publish() } - else -> _state.value = result.toUiState() + else -> _state.value = result.toShardUiState() } } } 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 index f218862..ee4dc52 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsViewModel.kt @@ -11,7 +11,7 @@ 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 com.runicgateway.app.ui.toShardUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor( board.seed(result.data) publish() } - else -> _state.value = result.toUiState() + else -> _state.value = result.toShardUiState() } } } 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 index dfae005..0601a28 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsViewModel.kt @@ -10,7 +10,7 @@ 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 com.runicgateway.app.ui.toShardUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor( board.seed(result.data) publish() } - else -> _state.value = result.toUiState() + else -> _state.value = result.toShardUiState() } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt index 9fb2bae..07acfb8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/HousesViewModel.kt @@ -10,7 +10,7 @@ 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 com.runicgateway.app.ui.toShardUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor( board.seed(result.data) publish() } - else -> _state.value = result.toUiState() + else -> _state.value = result.toShardUiState() } } } 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 index e889f34..bef6d86 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt @@ -32,12 +32,33 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.FeatureCard import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import com.runicgateway.app.data.repository.canSee import com.runicgateway.app.ui.components.PillTone import com.runicgateway.app.ui.components.SectionLabel import com.runicgateway.app.ui.components.StatusPill -/** Board destinations reachable from the hub. */ -enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES } +/** + * Board destinations reachable from the hub, each tagged with the visibility feature + * that governs it (M11). An admin can switch any of these off or raise its audience, + * so the hub's board list is filtered the same way the drawer is — a tile whose + * feature the caller can't see would only lead to a `404`/`403`. + */ +enum class ShardBoard(val feature: String) { + CHAMPS(ShardFeature.CHAMPS), + GUILDS(ShardFeature.GUILDS), + GOVERNORS(ShardFeature.GOVERNORS), + HOUSES(ShardFeature.HOUSES), +} + +/** + * The boards this viewer may reach. Pure + side-effect-free so the gating is + * unit-tested without Compose, exactly like `visibleEntries` for the drawer. An + * unknown answer shows every board — the server gates regardless (see [canSee]). + */ +fun visibleBoards(features: ShardFeatures?): List = + ShardBoard.entries.filter { canSee(features, it.feature) } /** * The Shard hub (PLAN.md §6.2): live connection status, online count + latest @@ -54,6 +75,7 @@ fun ShardScreen( val state by viewModel.state.collectAsStateWithLifecycle() val feed by viewModel.feed.collectAsStateWithLifecycle() val connected by viewModel.connected.collectAsStateWithLifecycle() + val features by viewModel.shardFeatures.collectAsStateWithLifecycle() when (val s = state) { is UiState.Loading -> LoadingView(modifier) @@ -62,6 +84,7 @@ fun ShardScreen( hub = s.data, feed = feed, connected = connected, + features = features, onOpenBoard = onOpenBoard, modifier = modifier, ) @@ -73,6 +96,7 @@ private fun HubContent( hub: ShardHub, feed: List, connected: Boolean, + features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit, modifier: Modifier = Modifier, ) { @@ -82,7 +106,7 @@ private fun HubContent( contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp), ) { item { StatusCard(hub.status, hub.presence?.count) } - item { BoardsCard(onOpenBoard) } + item { BoardsCard(features, onOpenBoard) } if (hub.online.isNotEmpty()) { item { SectionHeader(stringResource(R.string.shard_section_staff)) } @@ -159,13 +183,16 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) { } @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, - ) +private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) { + val boards = visibleBoards(features).map { board -> + board to when (board) { + ShardBoard.CHAMPS -> R.string.shard_nav_champs + ShardBoard.GUILDS -> R.string.shard_nav_guilds + ShardBoard.GOVERNORS -> R.string.shard_nav_governors + ShardBoard.HOUSES -> R.string.shard_nav_houses + } + } + if (boards.isEmpty()) return Card(Modifier.fillMaxWidth()) { Column { boards.forEachIndexed { index, (board, labelRes) -> 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 index 4b8e1fe..f55a9b8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ShardViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardViewModel.kt @@ -10,9 +10,11 @@ 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.ShardFeatures +import com.runicgateway.app.data.repository.ShardFeaturesRepository import com.runicgateway.app.data.repository.ShardRepository import com.runicgateway.app.ui.UiState -import com.runicgateway.app.ui.toUiState +import com.runicgateway.app.ui.toShardUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -39,11 +41,18 @@ data class ShardHub( @HiltViewModel class ShardViewModel @Inject constructor( private val repository: ShardRepository, + shardFeaturesRepository: ShardFeaturesRepository, ) : ViewModel() { private val _state = MutableStateFlow>(UiState.Loading) val state: StateFlow> = _state.asStateFlow() + /** + * Which boards to offer (M11). Read-only here — the app shell refreshes this on + * every session change, and the hub only filters its tiles with it. + */ + val shardFeatures: StateFlow = shardFeaturesRepository.features + private val _feed = MutableStateFlow>(emptyList()) val feed: StateFlow> = _feed.asStateFlow() @@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor( 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() + is ApiResult.HttpError -> _state.value = status.toShardUiState() + is ApiResult.NetworkError -> _state.value = status.toShardUiState() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ae4813..d104426 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ This content couldn\'t be found. Too many requests. Please try again in a moment. The shard is offline right now. + This shard doesn\'t publish this here. Something went wrong on the server. Please try again. @@ -290,6 +291,11 @@ Poison Energy Skills + Loyalty & Points + + %1$s · #%2$d + + %1$d / %2$d Equipment Item id %1$d diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt index d92b832..bf1f122 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -99,4 +100,57 @@ class PlayerShardDtoTest { assertTrue(dto.linked) assertEquals("whitlocktech", dto.account) } + + // ── Protocol 3.0 additions to char.profile ─────────────────────────── + + @Test fun charProfileDecodesThePointsBlock() { + // Shaped like a real shard's reply: an uncapped board (maxPoints 0), a + // cliloc-named board (nameString null), and no `rank` unless opted in. + val dto = json.decodeFromString( + """{"serial":"0x24C","name":"Darrow", + "points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty", + "points":29500,"maxPoints":30000,"rank":3}, + {"system":"VoidPool","nameString":null,"points":180,"maxPoints":0}]}""", + ) + assertEquals(2, dto.points.size) + val queens = dto.points[0] + assertEquals("Queen's Loyalty", queens.nameString) + assertEquals(29500L, queens.points) + assertEquals(30000L, queens.cap) + assertEquals(3, queens.rank) + + val voidPool = dto.points[1] + assertNull("maxPoints 0 means uncapped, not a zero cap", voidPool.cap) + assertNull("rank is absent unless the shard opts in", voidPool.rank) + assertNull(voidPool.nameString) + } + + @Test fun charProfileWithoutAPointsBlockDecodesToEmpty() { + // A shard plugin that predates Protocol 3.0 sends no `points` key at all. + val dto = json.decodeFromString("""{"serial":"0x24C","name":"Darrow"}""") + assertEquals(emptyList(), dto.points) + } + + @Test fun equipmentDecodesTheServerResolvedClilocName() { + val dto = json.decodeFromString( + """{"serial":"0x24C", + "equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"cliloc":1023721, + "clilocName":"hatchet"}, + {"serial":"0x41","layer":"Shirt","name":"Bob's lucky shirt", + "clilocName":"fancy shirt"}]}""", + ) + assertEquals("hatchet", dto.equipment[0].label) + assertEquals("Bob's lucky shirt", dto.equipment[1].label) + } + + @Test fun titlesDecodeTheParallelResolvedArrayIncludingItsNulls() { + // rewardResolved carries a null where the cliloc table had nothing; the array + // must stay positionally aligned with `reward`. + val dto = json.decodeFromString( + """{"selected":1,"reward":["1049565","1049566"], + "rewardResolved":[null,"Knight of Trinsic"]}""", + ) + assertEquals(listOf("1049565", "1049566"), dto.reward) + assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved) + } } diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt index f886973..2357091 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardDtoTest.kt @@ -111,4 +111,30 @@ class ShardDtoTest { assertEquals("bob", ActorDto(acct = "bob").label) assertEquals("Someone", ActorDto().label) } + + @Test fun actorArrivesWithoutAcctOrWebIdBelowTheAdminRung() { + // Those two fields are locked to `admin` by the visibility framework and are + // stripped from every response below it — the app must decode their absence, + // not depend on them (docs/link/v3.md §3.4 rule 1). + val dto = json.decodeFromString("""{"serial":"0x24C","name":"Darrow","player":true}""") + assertEquals("Darrow", dto.label) + assertNull(dto.acct) + assertNull(dto.webId) + } + + @Test fun shardFeaturesDecodesTheRungAndVisibleSet() { + val dto = json.decodeFromString( + """{"level":"player","features":["status","champs","guilds","market"]}""", + ) + assertEquals("player", dto.level) + assertTrue(dto.features.contains("market")) + assertEquals(4, dto.features.size) + } + + @Test fun shardFeaturesDecodesAnEmptySet() { + // A fully-gated shard: every feature switched off for this viewer. Distinct + // from the lookup failing, which the repository represents as null. + val dto = json.decodeFromString("""{"level":"anonymous","features":[]}""") + assertEquals(emptyList(), dto.features) + } } diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt index c09a842..75da7db 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt @@ -18,6 +18,7 @@ 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.ShardFeaturesDto import com.runicgateway.app.data.api.dto.ShardStatusDto import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.api.dto.WikiCategoryDto @@ -56,6 +57,7 @@ class FakePublicApi : PublicApi { var governors: List = emptyList() var governorHistory: List = emptyList() var houses: List = emptyList() + var shardFeatures: ShardFeaturesDto = ShardFeaturesDto() /** Last contact request body seen (so a test can assert it was trimmed/forwarded). */ var lastContact: ContactRequest? = null @@ -84,6 +86,7 @@ class FakePublicApi : PublicApi { return reply(contactResponse) } + override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures) override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus) override suspend fun getShardFeed(kind: String?, limit: Int?): List = reply(shardFeed) override suspend fun getShardEconomy(limit: Int?): List = reply(shardEconomy) diff --git a/app/src/test/java/com/runicgateway/app/data/repository/ShardFeaturesRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/ShardFeaturesRepositoryTest.kt new file mode 100644 index 0000000..836686f --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/ShardFeaturesRepositoryTest.kt @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.data.api.dto.ShardFeaturesDto +import com.runicgateway.app.data.api.fake.FakePublicApi +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * The shard-visibility lookup (PLAN.md §9 M11). The behavior worth pinning is the + * FAIL-OPEN direction: an unknown answer must show every entry, because the server + * gates every call regardless and the alternative is a menu that flickers in. + */ +class ShardFeaturesRepositoryTest { + + private val api = FakePublicApi() + private val repository = ShardFeaturesRepository(api) + + @Test fun refreshPublishesTheVisibleSetAndTheServersRung() = runTest { + api.shardFeatures = ShardFeaturesDto( + level = "player", + features = listOf("status", "champs", "market"), + ) + + repository.refresh() + + val features = repository.features.value + assertEquals("player", features?.level) + assertEquals(setOf("status", "champs", "market"), features?.visible) + } + + @Test fun aFeatureTheServerOmittedIsNotVisible() = runTest { + api.shardFeatures = ShardFeaturesDto(level = "anonymous", features = listOf("status")) + + repository.refresh() + + assertTrue(canSee(repository.features.value, ShardFeature.STATUS)) + assertFalse(canSee(repository.features.value, ShardFeature.MARKET)) + } + + @Test fun aFailedLookupFallsBackToUnknownRatherThanEmpty() = runTest { + // Empty and unknown are opposite answers: empty hides everything, unknown + // shows everything. A failure must never be read as "this shard publishes + // nothing". + api.error = IOException("offline") + + repository.refresh() + + assertNull(repository.features.value) + assertTrue(canSee(repository.features.value, ShardFeature.MARKET)) + } + + @Test fun aPreProtocol3WebsiteIs404AndReadsAsUnknown() = runTest { + // The route does not exist before Protocol 3.0. That site has no visibility + // framework at all, so "unknown" is exactly right and the menu behaves as it + // did before M11. + api.error = httpError(404) + + repository.refresh() + + assertNull(repository.features.value) + assertTrue(canSee(repository.features.value, ShardFeature.CHAMPS)) + } + + @Test fun aFailedRefreshClearsAPreviouslyGoodAnswer() = runTest { + api.shardFeatures = ShardFeaturesDto(level = "admin", features = listOf("status")) + repository.refresh() + assertEquals(setOf("status"), repository.features.value?.visible) + + // Signing out and failing to re-resolve must not leave the previous viewer's + // (possibly wider) answer in place. + api.error = httpError(500) + repository.refresh() + + assertNull(repository.features.value) + } + + @Test fun invalidateDropsTheCachedAnswer() = runTest { + api.shardFeatures = ShardFeaturesDto(level = "staff", features = listOf("houses")) + repository.refresh() + assertEquals("staff", repository.features.value?.level) + + // A Settings → Server switch: the answer belonged to the old host. + repository.invalidate() + + assertNull(repository.features.value) + } + + @Test fun canSeeTreatsUnknownAsVisibleAndEmptyAsHidden() { + assertTrue("unknown must fail open", canSee(null, ShardFeature.RULESET)) + assertFalse( + "an explicit empty set hides everything", + canSee(ShardFeatures(level = "anonymous", visible = emptySet()), ShardFeature.RULESET), + ) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt index 13352c4..17f101a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt @@ -4,7 +4,9 @@ package com.runicgateway.app.ui import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.ui.components.isRetryable import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.IOException @@ -34,6 +36,44 @@ class UiStateTest { assertTrue(ApiResult.HttpError(503).let { it.status == 503 }) } + // ── Shard reads: 404/403 mean "this shard doesn't publish it" (M11) ── + + @Test fun shardReadsTreat404And403AsFeatureUnavailable() { + // requireFeature answers 404 for a disabled feature (deliberately not + // disclosing that it exists) and 403 for a viewer below its audience rung. + assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(404)) + assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(403)) + } + + @Test fun shardReadsLeaveEveryOtherStatusAlone() { + assertEquals(ErrorKind.SHARD_OFFLINE, shardKindOf(503)) + assertEquals(ErrorKind.RATE_LIMITED, shardKindOf(429)) + assertEquals(ErrorKind.SERVER, shardKindOf(500)) + assertEquals( + ErrorKind.NETWORK, + (ApiResult.NetworkError(IOException()).toShardUiState() as UiState.Error).kind, + ) + assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toShardUiState()) + } + + @Test fun nonShardReadsKeep404AsNotFound() { + // The remap is scoped to shard routes on purpose: off them, a 404 is still a + // deleted post or an unknown wiki slug. + assertEquals(ErrorKind.NOT_FOUND, kindOf(404)) + } + + @Test fun anUnavailableFeatureIsNotRetryable() { + // An admin controls this, so a retry button would read as a transient failure + // the user could wait out. + assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE)) + for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) { + assertTrue("$kind should offer a retry", isRetryable(kind)) + } + } + private fun kindOf(status: Int): ErrorKind = (ApiResult.HttpError(status).toUiState() as UiState.Error).kind + + private fun shardKindOf(status: Int): ErrorKind = + (ApiResult.HttpError(status).toShardUiState() as UiState.Error).kind } diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuFeatureGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuFeatureGatingTest.kt new file mode 100644 index 0000000..0bca462 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuFeatureGatingTest.kt @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Role +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionUser +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import com.runicgateway.app.ui.shard.ShardBoard +import com.runicgateway.app.ui.shard.visibleBoards +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The second gate on a shard entry (PLAN.md §5, §9 M11): the shard's admin-configured + * visibility, independent of the session role. A signed-in admin still doesn't see a + * board the shard doesn't publish, and an anonymous visitor still doesn't see a + * signed-in entry however wide the feature config is. + */ +class MenuFeatureGatingTest { + + private fun signedIn(role: Role) = + Session.SignedIn(SessionUser(id = 1, username = "u", role = role)) + + private fun features(vararg visible: String) = + ShardFeatures(level = "anonymous", visible = visible.toSet()) + + private val shardEntry = MenuEntry("shard", 0, MenuAccess.PUBLIC, feature = ShardFeature.STATUS) + private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC) + + @Test fun aShardEntryHidesWhenItsFeatureIsNotVisible() { + val entries = listOf(plainEntry, shardEntry) + + val visible = visibleEntries(entries, Session.SignedOut, features("champs")).map { it.route } + + assertEquals(listOf("news"), visible) + } + + @Test fun aShardEntryShowsWhenItsFeatureIsVisible() { + val entries = listOf(plainEntry, shardEntry) + + val visible = visibleEntries(entries, Session.SignedOut, features("status")).map { it.route } + + assertEquals(listOf("news", "shard"), visible) + } + + @Test fun unknownFeaturesShowEverythingTheRoleAllows() { + // Fail open while the lookup is in flight or has failed — the server gates + // regardless, so a link that briefly 403s beats a nav that flickers in. + val entries = listOf(plainEntry, shardEntry) + + val visible = visibleEntries(entries, Session.SignedOut, features = null).map { it.route } + + assertEquals(listOf("news", "shard"), visible) + } + + @Test fun theTwoGatesAreIndependent() { + val staffShardEntry = MenuEntry("s", 0, MenuAccess.STAFF, feature = ShardFeature.HOUSES) + val entries = listOf(staffShardEntry) + + // Right role, feature switched off → hidden. + assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features("champs")).isEmpty()) + // Feature on, wrong role → hidden. + assertTrue(visibleEntries(entries, signedIn(Role.PLAYER), features("houses")).isEmpty()) + // Both → shown. + assertFalse(visibleEntries(entries, signedIn(Role.ADMIN), features("houses")).isEmpty()) + } + + @Test fun anAdminDoesNotBypassAFeatureGate() { + // The rung the server placed the caller on is what /features already accounts + // for. A staff role is not a licence to render a link to a disabled feature — + // a disabled feature 404s for everyone. + val entries = listOf(shardEntry) + + assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features()).isEmpty()) + } + + @Test fun everyShardMenuEntryDeclaresAFeature() { + // A shard-derived entry with no feature name silently skips the gate. The app + // menu's only such entry today is the Shard hub; this fails if one is added + // without one. + val shardRoutes = APP_MENU.filter { it.route == Routes.SHARD } + assertTrue(shardRoutes.isNotEmpty()) + assertTrue(shardRoutes.all { it.feature != null }) + } + + // ── The hub's board tiles use the same gate ────────────────────────── + @Test fun hubBoardsAreFilteredByFeature() { + val visible = visibleBoards(features("champs", "houses")) + + assertEquals(listOf(ShardBoard.CHAMPS, ShardBoard.HOUSES), visible) + } + + @Test fun hubBoardsShowAllWhenTheAnswerIsUnknown() { + assertEquals(ShardBoard.entries.toList(), visibleBoards(null)) + } + + @Test fun eachBoardMapsToItsOwnFeature() { + assertEquals(ShardFeature.CHAMPS, ShardBoard.CHAMPS.feature) + assertEquals(ShardFeature.GUILDS, ShardBoard.GUILDS.feature) + assertEquals(ShardFeature.GOVERNORS, ShardBoard.GOVERNORS.feature) + assertEquals(ShardFeature.HOUSES, ShardBoard.HOUSES.feature) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt b/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt index ade21e5..df547b2 100644 --- a/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt @@ -3,14 +3,18 @@ */ package com.runicgateway.app.ui.player +import com.runicgateway.app.data.api.dto.CharPointsDto +import com.runicgateway.app.data.api.dto.CharProfileDto +import com.runicgateway.app.data.api.dto.EquipmentDto import com.runicgateway.app.data.api.dto.TitlesDto import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test /** * Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the - * website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal* - * selected reward title, dropping bare cliloc numbers the app can't resolve. + * website's `CharacterSheet.jsx`: title selection over the server's cliloc-resolved + * parallel array, item naming precedence, and the Protocol 3.0 points block. */ class CharacterSheetHelpersTest { @@ -51,4 +55,100 @@ class CharacterSheetHelpersTest { val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great") assertEquals(listOf("The Great"), displayTitles(titles)) } + + // ── Cliloc-resolved titles (Protocol 3.0 §8.6) ─────────────────────── + + @Test fun displayTitlesPrefersTheServerResolvedRewardName() { + // The website resolves the numeric entries against its own cliloc table and + // sends a parallel array; the raw number is no longer the only thing we have. + val titles = TitlesDto( + selected = 0, + reward = listOf("1049565"), + rewardResolved = listOf("Knight of Trinsic"), + ) + assertEquals(listOf("Knight of Trinsic"), displayTitles(titles)) + } + + @Test fun displayTitlesKeepsSelectedAlignedWhenAnEntryDoesNotResolve() { + // rewardResolved is POSITIONAL. An entry the table had nothing for is null and + // must be skipped WITHOUT shifting `selected` onto its neighbour — otherwise + // the sheet confidently shows the wrong title. + val titles = TitlesDto( + selected = 1, + reward = listOf("1049565", "1049566"), + rewardResolved = listOf(null, "Knight of Trinsic"), + ) + assertEquals(listOf("Knight of Trinsic"), displayTitles(titles)) + } + + @Test fun displayTitlesFallsBackWhenTheSelectedTitleDidNotResolve() { + val titles = TitlesDto( + selected = 0, + reward = listOf("1049565", "1049566"), + rewardResolved = listOf(null, "Bane of Dragons"), + ) + assertEquals(listOf("Bane of Dragons"), displayTitles(titles)) + } + + @Test fun displayTitlesStillSkipsNumbersWhenNothingResolved() { + // A shard that configures no cliloc table sends no rewardResolved at all — + // the pre-3.0 behavior, unchanged. + val titles = TitlesDto(selected = 0, reward = listOf("1049565"), rewardResolved = emptyList()) + assertEquals(emptyList(), displayTitles(titles)) + } + + // ── Equipment names ────────────────────────────────────────────────── + + @Test fun itemLabelPrefersAPlayerGivenNameOverTheResolvedTypeName() { + // "Bob's lucky axe" must not be relabelled "hatchet". + val item = EquipmentDto(layer = "OneHanded", name = "Bob's lucky axe", clilocName = "hatchet") + assertEquals("Bob's lucky axe", item.label) + } + + @Test fun itemLabelFallsBackThroughClilocNameThenLayer() { + assertEquals("hatchet", EquipmentDto(layer = "OneHanded", clilocName = "hatchet").label) + assertEquals("OneHanded", EquipmentDto(layer = "OneHanded").label) + assertNull(EquipmentDto().label) + } + + // ── Loyalty & points (Protocol 3.0 §7.3) ───────────────────────────── + + @Test fun pointsLabelUsesTheHumanisedKeyWhenTheNameIsACliloc() { + // The PRIMARY path on a real shard: most systems name themselves with a + // cliloc, so nameString comes back null. + assertEquals("Queens Loyalty", pointsLabel(CharPointsDto(system = "QueensLoyalty"))) + assertEquals("Clean Up Britannia", pointsLabel(CharPointsDto(system = "CleanUpBritannia"))) + assertEquals("Void Pool", pointsLabel(CharPointsDto(system = "VoidPool"))) + } + + @Test fun pointsLabelPrefersTheShardsOwnNameWhenItHasOne() { + val entry = CharPointsDto(system = "QueensLoyalty", nameString = "Queen's Loyalty") + assertEquals("Queen's Loyalty", pointsLabel(entry)) + } + + @Test fun anUncappedSystemReportsNoCap() { + // maxPoints 0 means UNCAPPED and is the common case — three of five live + // boards on a real shard. Nothing may divide by it. + assertNull(CharPointsDto(points = 900, maxPoints = 0).cap) + assertNull(CharPointsDto(points = 900, maxPoints = null).cap) + assertEquals(30000L, CharPointsDto(points = 900, maxPoints = 30000).cap) + } + + @Test fun displayPointsDropsZeroesAndSortsByStandingDescending() { + val char = CharProfileDto( + points = listOf( + CharPointsDto(system = "A", points = 10), + CharPointsDto(system = "Zero", points = 0), + CharPointsDto(system = "B", points = 500), + CharPointsDto(system = "Null", points = null), + ), + ) + assertEquals(listOf("B", "A"), displayPoints(char).map { it.system }) + } + + @Test fun displayPointsIsEmptyForAProfileWithNoPointsBlock() { + // A pre-3.0 shard plugin sends none, and a new character has earned nothing — + // both render as nothing at all rather than an empty card. + assertEquals(emptyList(), displayPoints(CharProfileDto())) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt index 7c985ce..495738d 100644 --- a/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt @@ -12,6 +12,7 @@ import com.runicgateway.app.data.api.dto.PresenceDto import com.runicgateway.app.data.api.dto.ShardStatusDto import com.runicgateway.app.data.api.fake.FakePublicApi import com.runicgateway.app.data.api.fake.FakeShardStream +import com.runicgateway.app.data.repository.ShardFeaturesRepository import com.runicgateway.app.data.repository.ShardRepository import com.runicgateway.app.ui.UiState import com.runicgateway.app.util.MainDispatcherRule @@ -39,6 +40,10 @@ class ShardBoardViewModelTest { private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json) + // The hub reads the feature set only to filter its board tiles; these tests + // exercise loading, so the answer stays at its "unknown" default (show all). + private fun features() = ShardFeaturesRepository(api) + // ── Champs: snapshot + live upsert/remove ───────────────────────────── @Test fun champsSeedsSnapshotAndMergesLiveFrames() { api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor")) @@ -108,7 +113,7 @@ class ShardBoardViewModelTest { ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }), ), ) - val vm = ShardViewModel(repo(stream)) + val vm = ShardViewModel(repo(stream), features()) val hub = (vm.state.value as UiState.Success).data assertTrue(hub.status.isOnline) // presence.online frame patched the count in place. @@ -118,6 +123,6 @@ class ShardBoardViewModelTest { @Test fun shardHubStatusErrorIsUiError() { api.error = httpError(503) - assertTrue(ShardViewModel(repo()).state.value is UiState.Error) + assertTrue(ShardViewModel(repo(), features()).state.value is UiState.Error) } } -- 2.49.1 From aacef35def8ce6b1d97bf3f272df7ed6955456de Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 30 Jul 2026 02:50:19 -0500 Subject: [PATCH 2/3] feat(shard): the four Protocol 3.0 content screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M11 Part 2 (docs/android/PLAN.md §9), on the visibility plumbing Part 1 added. Each screen hides from the menu when the shard doesn't publish its feature, and self-reports "not available here" from its own 404/403 so a deep link still lands on an honest answer. - Rules (/public/shard/ruleset). A null body means the shard has never published a ruleset, which is a SUCCESS state, not the feature being off — the screen tells the two apart. Blocks render only when published, since an omitted block means the system is off rather than unknown. Skill caps are converted out of tenths; the raw 1000 reads as ten times the real limit. Live via world.ruleset, which the shard re-emits on every reconnect. - Leaderboards (/public/shard/points). Boards order most-contested first, live via points.board. maxPoints 0 is uncapped so no cap line is drawn, and a cliloc-named board (nameString null, the usual case) falls back to the humanised PointsType key. A nameless rank is a valid row: the character name is the feature's one admin-configurable field. - Market (/public/shard/market + /meta + /vendors/:serial). NOT live: the market feature ships with its SSE fan-out disabled, so this is a plain paginated read, searched on submit rather than per keystroke because it is the site's first rate-limited public endpoint. The staleness line is required, not decoration — the round-robin sweep means a price can be a full cycle old. The vendor screen is the only surface that can render a truncated shop and a gated location, the latter as a real answer rather than a blank coordinate. - Atlas (/public/atlas/creatures[/:slug]). Static shard content, so it stays readable while the shard is down — but site-mode gated, unlike /shard/*. Rows lead with the server's placement label ("Despise, Felucca"), which is the transform the whole feature exists for. Respawn delays are read as SECONDS, the unit the parser normalises XmlSpawner's mixed minutes/seconds into. Facet filter options are discovered from the shard's own data — nothing here names a facet, since a shard may add, replace or rename them. 336 unit tests pass (32 new); lint clean. The five-rung on-device walk runs against a local website on the cutover branch before the cutover merges. Co-Authored-By: Claude --- .../runicgateway/app/data/api/PublicApi.kt | 69 ++++ .../app/data/api/dto/ShardContentDto.kt | 339 ++++++++++++++++++ .../app/data/repository/ShardRepository.kt | 78 ++++ .../java/com/runicgateway/app/ui/RunicApp.kt | 33 ++ .../runicgateway/app/ui/navigation/Menu.kt | 6 + .../runicgateway/app/ui/navigation/Routes.kt | 18 + .../runicgateway/app/ui/shard/AtlasScreen.kt | 262 ++++++++++++++ .../app/ui/shard/AtlasViewModel.kt | 125 +++++++ .../app/ui/shard/LeaderboardsScreen.kt | 131 +++++++ .../app/ui/shard/LeaderboardsViewModel.kt | 101 ++++++ .../runicgateway/app/ui/shard/MarketScreen.kt | 260 ++++++++++++++ .../app/ui/shard/MarketViewModel.kt | 118 ++++++ .../runicgateway/app/ui/shard/RulesScreen.kt | 207 +++++++++++ .../app/ui/shard/RulesViewModel.kt | 63 ++++ app/src/main/res/values/strings.xml | 72 ++++ .../app/data/api/fake/FakePublicApi.kt | 60 ++++ .../app/ui/shard/ShardContentHelpersTest.kt | 172 +++++++++ .../app/ui/shard/ShardContentViewModelTest.kt | 223 ++++++++++++ 18 files changed, 2337 insertions(+) create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/AtlasViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/MarketViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/shard/RulesViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/shard/ShardContentViewModelTest.kt 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 850def6..ef1d654 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,6 +3,9 @@ */ package com.runicgateway.app.data.api +import com.runicgateway.app.data.api.dto.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto +import com.runicgateway.app.data.api.dto.AtlasMetaDto import com.runicgateway.app.data.api.dto.ChampDto import com.runicgateway.app.data.api.dto.ContactRequest import com.runicgateway.app.data.api.dto.ContactResponse @@ -12,10 +15,15 @@ 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.MarketMetaDto +import com.runicgateway.app.data.api.dto.MarketPageDto +import com.runicgateway.app.data.api.dto.MarketVendorDto import com.runicgateway.app.data.api.dto.OnlineStaffDto import com.runicgateway.app.data.api.dto.PageDto +import com.runicgateway.app.data.api.dto.PointsBoardDto import com.runicgateway.app.data.api.dto.PostDto import com.runicgateway.app.data.api.dto.PresenceDto +import com.runicgateway.app.data.api.dto.RulesetDto import com.runicgateway.app.data.api.dto.SettingsDto import com.runicgateway.app.data.api.dto.ShardFeaturesDto import com.runicgateway.app.data.api.dto.ShardStatusDto @@ -137,4 +145,65 @@ interface PublicApi { @GET("api/v1/public/shard/houses") suspend fun getShardHouses(): List + + // ── Protocol 3.0 shard content (§9 M11) ────────────────────────────── + // + // Each of these sits behind the website's `requireFeature` gate: a 404 means the + // shard doesn't publish it and a 403 means this viewer is below its audience rung, + // which `toShardUiState()` folds into one "not available here" state. + + /** The shard's configured ruleset. A `null` body means "not published yet". */ + @GET("api/v1/public/shard/ruleset") + suspend fun getShardRuleset(): RulesetDto? + + /** Every points/loyalty leaderboard the shard publishes. */ + @GET("api/v1/public/shard/points") + suspend fun getShardPoints(): List + + @GET("api/v1/public/shard/points/{system}") + suspend fun getShardPointsBoard(@Path("system") system: String): PointsBoardDto + + /** + * Search the player-vendor index. **Rate-limited** — the first genuinely expensive + * public endpoint on the site, so handle `429` (`ErrorKind.RATE_LIMITED`). + */ + @GET("api/v1/public/shard/market") + suspend fun getShardMarket( + @Query("q") query: String? = null, + @Query("minPrice") minPrice: Long? = null, + @Query("maxPrice") maxPrice: Long? = null, + @Query("map") map: String? = null, + @Query("region") region: String? = null, + @Query("sort") sort: String? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): MarketPageDto + + /** Index size, staleness, and which facets/regions actually hold vendors. */ + @GET("api/v1/public/shard/market/meta") + suspend fun getShardMarketMeta(): MarketMetaDto + + @GET("api/v1/public/shard/market/vendors/{serial}") + suspend fun getShardMarketVendor( + @Path("serial") serial: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): MarketVendorDto + + // The atlas lives under /public/atlas, NOT /public/shard: it is static shard + // content parsed from the server's data files, so it stays readable while the + // shard is down — but it IS site-mode gated, unlike the shard routes. + @GET("api/v1/public/atlas/creatures") + suspend fun getAtlasCreatures( + @Query("q") query: String? = null, + @Query("facet") facet: String? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): AtlasCreaturePageDto + + @GET("api/v1/public/atlas/creatures/{slug}") + suspend fun getAtlasCreature(@Path("slug") slug: String): AtlasCreatureDto + + @GET("api/v1/public/atlas/meta") + suspend fun getAtlasMeta(): AtlasMetaDto } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt new file mode 100644 index 0000000..0999f84 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt @@ -0,0 +1,339 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11): + * the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn + * atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas. + * controller.js` responses; see `docs/link/v3.md` §5–§8. + * + * Every field is nullable-with-a-default, which is load-bearing rather than merely + * defensive here: an admin can gate individual fields away per audience rung + * (`ownerName`, `location`, a board's `name`), so a response legitimately arrives + * with them missing and must still decode. + */ + +// ── Ruleset (§5) ──────────────────────────────────────────────────────────── + +/** + * `GET /public/shard/ruleset` — what this shard's world is configured to do. + * + * Every block is optional and omitted when its system is off, so a null block means + * "not applicable here", not "unknown". A `null` BODY (rather than an empty object) + * means the shard has never published a ruleset — distinct from the feature being + * switched off, which is a 404. + */ +@Serializable +data class RulesetDto( + val shard: String? = null, + val expansion: String? = null, + /** + * The public connect address, published only when the operator set one. It is + * also the ruleset's one admin-configurable field, so it can be present for a + * signed-in viewer and absent for an anonymous one. + */ + val connect: String? = null, + /** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */ + val systems: Map = emptyMap(), + val caps: RulesetCapsDto? = null, + val accounts: RulesetAccountsDto? = null, + val housing: RulesetHousingDto? = null, + val vetRewards: RulesetVetRewardsDto? = null, + val vendors: RulesetVendorsDto? = null, + val vvv: RulesetVvvDto? = null, + val store: RulesetStoreDto? = null, + val schedule: RulesetScheduleDto? = null, + val updatedAt: String? = null, +) + +/** + * Skill and stat caps. + * + * **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores + * them, and the raw number is actively misleading rather than merely unhelpful (a + * "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap] + * and [totalSkillCap]. The stat caps below them are plain values. + */ +@Serializable +data class RulesetCapsDto( + val skill: Int? = null, + val totalSkill: Int? = null, + val stat: Int? = null, + val str: Int? = null, + val dex: Int? = null, + val int: Int? = null, + val strMax: Int? = null, + val dexMax: Int? = null, + val intMax: Int? = null, +) { + val skillCap: Double? get() = skill?.let { it / 10.0 } + val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 } +} + +@Serializable +data class RulesetAccountsDto( + val perIp: Int? = null, + val charSlots: Int? = null, + val autoCreate: Boolean? = null, +) + +@Serializable +data class RulesetHousingDto(val accountHouseLimit: Int? = null) + +@Serializable +data class RulesetVetRewardsDto( + val enabled: Boolean? = null, + val rewardIntervalDays: Int? = null, +) + +@Serializable +data class RulesetVendorsDto( + val restockDelayMinutes: Int? = null, + val maxSell: Int? = null, + val economyStockAmount: Int? = null, +) + +@Serializable +data class RulesetVvvDto( + val enabled: Boolean? = null, + val startSilver: Int? = null, + val enhancedRules: Boolean? = null, +) + +@Serializable +data class RulesetStoreDto( + val enabled: Boolean? = null, + val currencyName: String? = null, +) + +@Serializable +data class RulesetScheduleDto( + val autoSaveFrequencyMinutes: Int? = null, + val autoRestartEnabled: Boolean? = null, + val autoRestartHour: Int? = null, + val autoRestartMinute: Int? = null, +) + +// ── Leaderboards (§7) ─────────────────────────────────────────────────────── + +/** + * One point system's board (`GET /public/shard/points`, `/points/:system`). + * + * [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is + * usually null because most systems name themselves with a cliloc — the same two + * traps as [CharPointsDto], documented in full there. + * + * [players] counts players actually *holding* points, not the entry count: ten of the + * shard's systems auto-add a zero-point row for every character ever created, so the + * raw count would report the whole census as one system's participants. + */ +@Serializable +data class PointsBoardDto( + val system: String? = null, + val nameString: String? = null, + val nameNumber: Int? = null, + val maxPoints: Long? = null, + val players: Int? = null, + val showOnGump: Boolean = true, + val top: List = emptyList(), + val t: Long? = null, + val updatedAt: String? = null, +) { + /** The cap, or null when the system is uncapped. */ + val cap: Long? get() = maxPoints?.takeIf { it > 0 } +} + +/** + * A ranked character on a board. [name] is admin-configurable (the `leaderboards` + * feature's one field rule), so a shard can publish standings without naming who + * holds them — a rank with no name is a valid row, not a broken one. + */ +@Serializable +data class PointsEntryDto( + val rank: Int? = null, + val serial: String? = null, + val name: String? = null, + val points: Long? = null, +) + +// ── Marketplace (§8) ──────────────────────────────────────────────────────── + +/** + * Where a shop stands. **Nested, not flattened**, on the wire and in the read model + * alike, so that ONE admin rule hides the facet, the coordinates, the region and the + * house together — five flat keys would be five rules that drift apart (`v3.md` §8.8). + * A null location means an admin gated it away; render that as an answer, not a blank. + */ +@Serializable +data class MarketLocationDto( + val map: String? = null, + val x: Int? = null, + val y: Int? = null, + val z: Int? = null, + val region: String? = null, + val house: String? = null, +) + +/** The shop a listing belongs to, as embedded in a search result. */ +@Serializable +data class MarketVendorRefDto( + val serial: String? = null, + val shopName: String? = null, + val ownerName: String? = null, + val location: MarketLocationDto? = null, +) + +/** + * One item for sale. [displayName] is resolved server-side against the site's cliloc + * table, preferring a player-set [name]; a shard with no cliloc table configured sends + * neither and the item renders by id. + * + * [child] marks an item priced by an enclosing container rather than itself, exactly + * as the in-game Vendor Search reports it. + */ +@Serializable +data class MarketListingDto( + val serial: String? = null, + val itemId: Int? = null, + val hue: Int? = null, + val amount: Int? = null, + val price: Long? = null, + val name: String? = null, + val cliloc: Int? = null, + val displayName: String? = null, + val child: Boolean = false, + val vendor: MarketVendorRefDto? = null, +) { + /** What to call this item; null when the shard publishes no name for it. */ + val label: String? get() = name ?: displayName +} + +/** + * A page of search results (`GET /public/shard/market`). + * + * Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much" + * is the question, and a vendor-shaped result would make every caller flatten the + * shops back out. + * + * [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The + * shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old; + * a page implying live prices sends someone to an item that sold twenty minutes ago. + */ +@Serializable +data class MarketPageDto( + val listings: List = emptyList(), + val total: Int = 0, + val limit: Int? = null, + val offset: Int? = null, + val vendors: Int? = null, + val staleAt: String? = null, +) + +/** + * One shop and its stock (`GET /public/shard/market/vendors/:serial`). + * + * [truncated] means the shard publishes only the first `MarketMaxListings` of a larger + * inventory — [count] is what is published, [total] what the shop holds. Saying so is + * the point of this screen: a search result list cannot express it. + */ +@Serializable +data class MarketVendorDto( + val serial: String? = null, + val shopName: String? = null, + val ownerSerial: String? = null, + val ownerName: String? = null, + val location: MarketLocationDto? = null, + val count: Int? = null, + val total: Int? = null, + val truncated: Boolean = false, + val updatedAt: String? = null, + val items: List = emptyList(), +) + +/** Index size, staleness and the filter options that actually hold vendors. */ +@Serializable +data class MarketMetaDto( + val vendors: Int = 0, + val items: Int = 0, + val staleAt: String? = null, + val freshAt: String? = null, + val maps: List = emptyList(), + val regions: List = emptyList(), +) + +// ── Spawn atlas (§6) ──────────────────────────────────────────────────────── + +/** + * A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`: + * the atlas is static shard *content* parsed from the server's own data files, not + * live shard *state*, so it does not go offline with the sidecar — but unlike the + * shard routes it IS site-mode gated, like posts and the wiki. + * + * [points] is a **count** of spawners; [spawners] is the list, and only the + * single-creature route sends it. The two names are one letter apart in meaning and + * were deliberately separated (`v3.md` §6.3) — do not reuse one for the other. + */ +@Serializable +data class AtlasCreatureDto( + val slug: String? = null, + val name: String? = null, + /** How many can be alive at once, summed across every spawner. */ + val total: Int? = null, + /** How many spawners mention this creature. */ + val points: Int? = null, + /** Spawner count per facet. */ + val facets: Map = emptyMap(), + /** Region/landmark names where it appears — the detail route only. */ + val places: List = emptyList(), + val spawners: List = emptyList(), + val spawnersTruncated: Boolean = false, + /** Creatures sharing its spawners — the detail route only. */ + val alsoHere: List = emptyList(), +) + +/** + * One spawn point. + * + * **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser. + * XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole + * minutes, flagging that per record — so the raw file has `5` meaning five minutes on + * one spawner and five seconds on the next, both plausible. The API and this client + * carry seconds throughout. + */ +@Serializable +data class AtlasSpawnerDto( + val id: Long? = null, + val facet: String? = null, + val name: String? = null, + val x: Int? = null, + val y: Int? = null, + val maxCount: Int? = null, + val minDelay: Int? = null, + val maxDelay: Int? = null, + val region: String? = null, + val landmark: String? = null, + /** The server's own "Despise, Felucca" style placement label. */ + val label: String? = null, +) + +/** A page of creature search results (`GET /public/atlas/creatures`). */ +@Serializable +data class AtlasCreaturePageDto( + val creatures: List = emptyList(), + val total: Int = 0, + val limit: Int? = null, + val offset: Int? = null, +) + +/** When the atlas was last derived from the shard's data files, and what it holds. */ +@Serializable +data class AtlasMetaDto( + val importedAt: String? = null, + val generatedAt: String? = null, + val counts: Map = emptyMap(), + val facets: List = emptyList(), +) 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 index 0856515..b563d6d 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt @@ -8,6 +8,8 @@ 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.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto import com.runicgateway.app.data.api.dto.ChampDto import com.runicgateway.app.data.api.dto.EconomySampleDto import com.runicgateway.app.data.api.dto.FeedEventDto @@ -15,8 +17,13 @@ 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.MarketMetaDto +import com.runicgateway.app.data.api.dto.MarketPageDto +import com.runicgateway.app.data.api.dto.MarketVendorDto import com.runicgateway.app.data.api.dto.OnlineStaffDto +import com.runicgateway.app.data.api.dto.PointsBoardDto import com.runicgateway.app.data.api.dto.PresenceDto +import com.runicgateway.app.data.api.dto.RulesetDto import com.runicgateway.app.data.api.dto.ShardStatusDto import kotlinx.coroutines.flow.Flow import kotlinx.serialization.KSerializer @@ -62,6 +69,59 @@ class ShardRepository @Inject constructor( suspend fun houses(): ApiResult> = safeApiCall { api.getShardHouses() } + // ── Protocol 3.0 shard content (§9 M11) ────────────────────────────── + // + // All four sit behind `requireFeature`, so a 404/403 here is "this shard doesn't + // publish it" rather than a fault — see `toShardUiState()`. + + /** The shard ruleset, or `Ok(null)` when the shard has never published one. */ + suspend fun ruleset(): ApiResult = safeApiCall { api.getShardRuleset() } + + suspend fun pointsBoards(): ApiResult> = safeApiCall { api.getShardPoints() } + + suspend fun pointsBoard(system: String): ApiResult = + safeApiCall { api.getShardPointsBoard(system) } + + suspend fun market( + query: String? = null, + map: String? = null, + region: String? = null, + sort: String = SORT_PRICE_ASC, + limit: Int = MARKET_PAGE, + offset: Int = 0, + ): ApiResult = safeApiCall { + api.getShardMarket( + query = query?.takeIf { it.isNotBlank() }, + map = map?.takeIf { it.isNotBlank() }, + region = region?.takeIf { it.isNotBlank() }, + sort = sort, + limit = limit, + offset = offset, + ) + } + + suspend fun marketMeta(): ApiResult = safeApiCall { api.getShardMarketMeta() } + + suspend fun marketVendor(serial: String): ApiResult = + safeApiCall { api.getShardMarketVendor(serial) } + + suspend fun atlasCreatures( + query: String? = null, + facet: String? = null, + limit: Int = ATLAS_PAGE, + offset: Int = 0, + ): ApiResult = safeApiCall { + api.getAtlasCreatures( + query = query?.takeIf { it.isNotBlank() }, + facet = facet?.takeIf { it.isNotBlank() }, + limit = limit, + offset = offset, + ) + } + + suspend fun atlasCreature(slug: String): ApiResult = + safeApiCall { api.getAtlasCreature(slug) } + // ── Live stream ────────────────────────────────────────────────────── /** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */ fun liveEvents(): Flow = stream.events() @@ -73,9 +133,27 @@ class ShardRepository @Inject constructor( fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer()) fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer()) + // Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by + // default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out + // disabled (a live firehose of vendor inventories would be the site's biggest + // bandwidth consumer), so the market screen is a plain paginated read and must never + // wait on a frame. + fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer()) + fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer()) + private fun decode(obj: JsonObject, serializer: KSerializer): T? = try { json.decodeFromJsonElement(serializer, obj) } catch (_: Exception) { null } + + companion object { + const val SORT_PRICE_ASC = "price_asc" + const val SORT_PRICE_DESC = "price_desc" + const val SORT_RECENT = "recent" + + /** The server caps `limit` at 100; stay well under it on a phone. */ + const val MARKET_PAGE = 50 + const val ATLAS_PAGE = 50 + } } 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 3f55d48..532a7f6 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -72,10 +72,16 @@ import com.runicgateway.app.ui.player.CharactersScreen import com.runicgateway.app.ui.player.MyHousesScreen import com.runicgateway.app.ui.player.VendorsScreen import com.runicgateway.app.ui.session.SessionViewModel +import com.runicgateway.app.ui.shard.AtlasCreatureScreen +import com.runicgateway.app.ui.shard.AtlasScreen 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.LeaderboardsScreen +import com.runicgateway.app.ui.shard.MarketScreen +import com.runicgateway.app.ui.shard.MarketVendorScreen +import com.runicgateway.app.ui.shard.RulesScreen import com.runicgateway.app.ui.shard.ShardBoard import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.wiki.WikiPageScreen @@ -85,6 +91,9 @@ import kotlinx.coroutines.launch /** Destinations that show the drawer (hamburger); others show a back arrow. */ private val TOP_LEVEL_ROUTES = setOf( Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, + // Protocol 3.0 content screens are drawer destinations, so the drawer gesture works + // on them too (M11). + Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS, Routes.NOTIFICATIONS, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, @@ -305,6 +314,30 @@ private fun RunicNavHost( composable(Routes.SHARD_GUILDS) { GuildsScreen() } composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() } composable(Routes.SHARD_HOUSES) { HousesScreen() } + + // Protocol 3.0 shard content (M11). Each screen self-reports "not published + // here" from its own 404/403, so a deep link to a gated feature still lands on + // an honest answer even though the menu hides the entry. + composable(Routes.SHARD_RULES) { RulesScreen() } + composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen() } + composable(Routes.SHARD_MARKET) { + MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) }) + } + composable( + route = Routes.SHARD_MARKET_VENDOR, + arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }), + ) { entry -> + MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty()) + } + composable(Routes.ATLAS) { + AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) }) + } + composable( + route = Routes.ATLAS_CREATURE, + arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }), + ) { entry -> + AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty()) + } composable(Routes.WIKI) { WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) }) } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index abe96c8..994a9fb 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -64,6 +64,12 @@ val APP_MENU: List = listOf( MenuEntry(Routes.NEWS, R.string.menu_news), MenuEntry(Routes.WIKI, R.string.menu_wiki), MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS), + // Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it, + // which for a brand-new install is every one of them until the plugin has swept. + MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET), + MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS), + MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS), + MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET), MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), 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 cc83ba9..449f980 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 @@ -34,6 +34,18 @@ object Routes { const val SHARD_GOVERNORS = "shard/governors" const val SHARD_HOUSES = "shard/houses" + /** + * Protocol 3.0 shard content (M11), each gated by its own visibility feature. The + * atlas is not under `shard/` on the wire (`/public/atlas`) because it is static + * content rather than live state, but it is a peer of these in the app's nav. + */ + const val SHARD_RULES = "shard/rules" + const val SHARD_LEADERBOARDS = "shard/leaderboards" + const val SHARD_MARKET = "shard/market" + const val SHARD_MARKET_VENDOR = "shard/market/{serial}" + const val ATLAS = "atlas" + const val ATLAS_CREATURE = "atlas/{slug}" + /** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */ const val PLAYER_CHARACTERS = "player/characters" const val PLAYER_VENDORS = "player/vendors" @@ -72,6 +84,12 @@ object Routes { /** The character-sheet route for an in-game serial (e.g. "0x24C"). */ fun playerChar(serial: String) = "player/char/$serial" + /** One player vendor's shop, by in-game (hex) serial. */ + fun marketVendor(serial: String) = "shard/market/$serial" + + /** One creature's atlas page, by slug. */ + fun atlasCreature(slug: String) = "atlas/$slug" + /** * The in-app destination a tapped push notification deep-links to (§11, M7 * Part 2 work item 7). Maps a stream id to the screen that shows its content; diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt new file mode 100644 index 0000000..8ceb220 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt @@ -0,0 +1,262 @@ +/* + * 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +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.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasSpawnerDto +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 +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.StatusPill + +/** + * The spawn atlas / bestiary (PLAN.md §9 M11): "where do I find X". + * + * The whole point of the feature is the placement transform the server does — a spawn + * at 5411,1234 becomes *"Despise, Felucca"* — so a row leads with where a creature is + * found, not with coordinates. + */ +@Composable +fun AtlasScreen( + onOpenCreature: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: AtlasViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val query by viewModel.query.collectAsStateWithLifecycle() + + Column(modifier.fillMaxSize()) { + OutlinedTextField( + value = query, + onValueChange = viewModel::onQueryChange, + label = { Text(stringResource(R.string.atlas_search_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { viewModel.search() }), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + + when (val s = state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load) + is UiState.Success -> { + if (s.data.creatures.isEmpty()) { + EmptyView(stringResource(R.string.atlas_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.creatures, key = { it.slug.orEmpty() }) { creature -> + CreatureCard(creature, onOpenCreature) + } + } + } + } + } + } +} + +@Composable +private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) { + val slug = creature.slug + Card( + Modifier + .fillMaxWidth() + .then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier), + ) { + Column(Modifier.padding(16.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = creature.name ?: slug.orEmpty(), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // `points` is a COUNT of spawners on this route; `spawners` is the list, + // and only the detail route sends it. + creature.points?.let { + Text( + text = stringResource(R.string.atlas_spawner_count, it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + facetSummary(creature)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** One creature: every spawner, where it stands, and what shares its spawns. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AtlasCreatureScreen( + slug: String, + modifier: Modifier = Modifier, + viewModel: AtlasCreatureViewModel = hiltViewModel(), +) { + LaunchedEffect(slug) { viewModel.load(slug) } + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val s = state) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier) + is UiState.Success -> { + val creature = s.data + LazyColumn( + modifier = modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp), + ) { + item { + Column { + Text( + creature.name ?: creature.slug.orEmpty(), + style = MaterialTheme.typography.titleLarge, + ) + creature.total?.let { + Text( + stringResource(R.string.atlas_total_alive, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (creature.facets.isNotEmpty()) { + FlowRow( + Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + creature.facets.entries.sortedBy { it.key }.forEach { (facet, count) -> + StatusPill( + text = stringResource(R.string.atlas_facet_count, facet, count), + tone = PillTone.Neutral, + ) + } + } + } + } + } + if (creature.spawners.isNotEmpty()) { + item { SectionLabel(stringResource(R.string.atlas_section_spawners)) } + items(creature.spawners, key = { it.id ?: it.hashCode().toLong() }) { spawner -> + SpawnerRow(spawner) + } + if (creature.spawnersTruncated) { + item { + Text( + stringResource(R.string.atlas_spawners_truncated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (creature.alsoHere.isNotEmpty()) { + item { SectionLabel(stringResource(R.string.atlas_section_also_here)) } + item { + Text( + creature.alsoHere.mapNotNull { it.name ?: it.slug }.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun SpawnerRow(spawner: AtlasSpawnerDto) { + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + text = spawnerPlace(spawner), + style = MaterialTheme.typography.bodyMedium, + ) + val meta = listOfNotNull( + spawner.maxCount?.let { stringResource(R.string.atlas_max_count, it) }, + // Seconds, normalised server-side — the raw XmlSpawner values are minutes + // OR seconds per record. + formatRespawn(spawner.minDelay, spawner.maxDelay) + ?.let { stringResource(R.string.atlas_respawn, it) }, + ).joinToString(" · ") + if (meta.isNotBlank()) { + Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +// ── Pure helpers (unit-tested) ─────────────────────────────────────────────── + +/** + * Where a spawner stands, preferring the server's own placement label — the + * point-in-rect transform is what turns a coordinate into "Despise, Felucca" and is + * the reason this feature exists. Falls back through region, landmark, and finally the + * raw coordinates, which is honest rather than useless for the ~17% of spawns that + * resolve to no named place. + */ +internal fun spawnerPlace(spawner: AtlasSpawnerDto): String { + spawner.label?.takeIf { it.isNotBlank() }?.let { return it } + val place = spawner.region ?: spawner.landmark + val facet = spawner.facet + return when { + place != null && facet != null -> "$place, $facet" + place != null -> place + spawner.x != null && spawner.y != null -> + listOfNotNull(facet, "${spawner.x}, ${spawner.y}").joinToString(" ") + else -> facet.orEmpty() + } +} + +/** + * A creature's facets as one line, most spawners first — "where is it *mostly*" is the + * question a search result answers. + */ +internal fun facetSummary(creature: AtlasCreatureDto): String? { + if (creature.facets.isEmpty()) return null + return creature.facets.entries + .sortedByDescending { it.value } + .joinToString(", ") { it.key } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasViewModel.kt new file mode 100644 index 0000000..7ab9e3b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasViewModel.kt @@ -0,0 +1,125 @@ +/* + * 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.result.ApiResult +import com.runicgateway.app.data.api.dto.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto +import com.runicgateway.app.data.repository.ShardRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toShardUiState +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 spawn atlas / bestiary (PLAN.md §9 M11, `docs/link/v3.md` §6): where each + * creature spawns, derived server-side from the shard's own data files. + * + * Static shard **content**, not live state — it does not go offline with the sidecar, + * and it lives under `/public/atlas`, not `/public/shard`. Unlike the shard routes it + * IS site-mode gated, so a site in maintenance withholds it independently. + */ +@HiltViewModel +class AtlasViewModel @Inject constructor( + private val repository: ShardRepository, +) : ViewModel() { + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _facet = MutableStateFlow(null) + val facet: StateFlow = _facet.asStateFlow() + + /** + * The facets this shard actually has. Discovered from the atlas itself — a shard + * may add, replace or rename facets when its maps change, so nothing here may name + * one (`v3.md` §6.1 R2). + */ + private val _facets = MutableStateFlow>(emptyList()) + val facets: StateFlow> = _facets.asStateFlow() + + init { + load() + } + + fun onQueryChange(value: String) { + _query.value = value + } + + fun onFacetChange(value: String?) { + if (value == _facet.value) return + _facet.value = value + search() + } + + fun search() = load() + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + val page = repository.atlasCreatures(query = _query.value, facet = _facet.value) + if (page is ApiResult.Ok && _facets.value.isEmpty()) { + // Only the first successful page needs to establish the filter options; + // a filtered page would otherwise narrow them to its own results. + _facets.value = page.data.creatures + .flatMap { it.facets.keys } + .distinct() + .sorted() + } + _state.value = page.toShardUiState() + } + } +} + +/** One creature's detail page: every spawner, and what else shares them. */ +@HiltViewModel +class AtlasCreatureViewModel @Inject constructor( + private val repository: ShardRepository, +) : ViewModel() { + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + private var slug: String? = null + + fun load(slug: String) { + this.slug = slug + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.atlasCreature(slug).toShardUiState() + } + } + + fun retry() { + slug?.let { load(it) } + } +} + +/** + * A respawn delay as text. **The API carries SECONDS** — XmlSpawner stores minutes + * except when a delay doesn't divide into whole minutes, and the server's parser + * normalises the two spellings so a `5` is never ambiguous here (`v3.md` §6.3). + * + * Pure, so the unit conversion is unit-tested rather than eyeballed on a page. + */ +internal fun formatRespawn(minSeconds: Int?, maxSeconds: Int?): String? { + val lo = minSeconds ?: maxSeconds ?: return null + val hi = maxSeconds ?: minSeconds ?: return null + return if (lo == hi) humaniseSeconds(lo) else "${humaniseSeconds(lo)}–${humaniseSeconds(hi)}" +} + +private fun humaniseSeconds(seconds: Int): String = when { + seconds < 60 -> "${seconds}s" + seconds % 60 == 0 -> "${seconds / 60}m" + else -> "${seconds / 60}m ${seconds % 60}s" +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt new file mode 100644 index 0000000..bf22614 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.shard + +import androidx.compose.foundation.layout.Arrangement +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.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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.PointsBoardDto +import com.runicgateway.app.data.api.dto.PointsEntryDto +import com.runicgateway.app.ui.components.SectionLabel + +/** + * The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via + * `points.board` frames. + */ +@Composable +fun LeaderboardsScreen( + modifier: Modifier = Modifier, + viewModel: LeaderboardsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val connected by viewModel.connected.collectAsStateWithLifecycle() + + LiveBoardScreen( + emptyMessage = stringResource(R.string.leaderboards_empty), + state = state, + connected = connected, + onRetry = viewModel::load, + key = { it.system.orEmpty() }, + modifier = modifier, + ) { board -> BoardCard(board) } +} + +@Composable +private fun BoardCard(board: PointsBoardDto) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = boardLabel(board), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + board.players?.let { + Text( + text = stringResource(R.string.leaderboards_players, it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + // A cap is worth stating only when there is one; most systems on a real + // shard are uncapped (maxPoints 0), and "/ 0" would be nonsense. + board.cap?.let { + SectionLabel(stringResource(R.string.leaderboards_cap, it)) + } + + if (board.top.isEmpty()) { + Text( + stringResource(R.string.leaderboards_board_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + } else { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + board.top.forEach { entry -> EntryRow(entry) } + } + } + } +} + +@Composable +private fun EntryRow(entry: PointsEntryDto) { + Row( + Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource( + R.string.leaderboards_rank_name, + entry.rank ?: 0, + // The character name is admin-configurable — a shard can publish + // standings without naming who holds them, so a nameless rank is a + // valid row rather than a broken one. + entry.name ?: stringResource(R.string.leaderboards_hidden_name), + ), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = (entry.points ?: 0L).toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * A board's display name: the shard's own literal when it has one, else the humanised + * `PointsType` key. The fallback is the PRIMARY path — four of five boards on a real + * shard name themselves with a cliloc and send `nameString: null`. + */ +internal fun boardLabel(board: PointsBoardDto): String { + board.nameString?.takeIf { it.isNotBlank() }?.let { return it } + return board.system.orEmpty() + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replaceFirstChar { it.uppercaseChar() } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsViewModel.kt new file mode 100644 index 0000000..7dba590 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsViewModel.kt @@ -0,0 +1,101 @@ +/* + * 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.PointsBoardDto +import com.runicgateway.app.data.repository.ShardRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toShardUiState +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 points/loyalty leaderboards (PLAN.md §9 M11, `docs/link/v3.md` §7): one board + * per point currency the shard publishes, each with its top ranks. + * + * Served from the website's own store, so the page renders while the shard is down — + * which matters more here than for live state: these are standings accumulated over + * months, and blanking them during a restart would look like data loss. + * + * Kept live by `points.board` frames, one per system, merged in place by [LiveBoard]. + */ +@HiltViewModel +class LeaderboardsViewModel @Inject constructor( + private val repository: ShardRepository, +) : ViewModel() { + + private val board = LiveBoard { it.system.orEmpty() } + + 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.pointsBoards()) { + is ApiResult.Ok -> { + board.seed(result.data) + publish() + } + else -> _state.value = result.toShardUiState() + } + } + } + + 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) { + // There is deliberately no `points.remove` on the wire: the system set is fixed + // for a given shard build, the same argument `city.update` makes. + if (frame.kind != "points.board") return + repository.pointsBoardFrame(frame.data)?.let { board.upsert(it) } + if (_state.value is UiState.Success) publish() + } + + private fun publish() { + _state.value = UiState.Success(orderBoards(board.values())) + } +} + +/** + * Board display order: most-contested first, then by name, so the boards people + * actually compete on lead. Pure, so the ordering is unit-tested. + * + * Boards the shard flags as not player-facing (`showOnGump = false`) are dropped — + * that is the shard's own "is this for players?" signal and the plugin already filters + * on it, so this only guards a shard configured to publish extras. + */ +internal fun orderBoards(boards: Collection): List = + boards + .filter { it.showOnGump } + .sortedWith( + compareByDescending { it.players ?: 0 } + .thenBy { (it.nameString ?: it.system).orEmpty().lowercase() }, + ) diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt new file mode 100644 index 0000000..ab2ddd6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt @@ -0,0 +1,260 @@ +/* + * 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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +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.MarketListingDto +import com.runicgateway.app.data.api.dto.MarketLocationDto +import com.runicgateway.app.data.api.dto.MarketVendorDto +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 +import com.runicgateway.app.ui.components.SectionLabel + +/** + * The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock. + * + * The staleness line under the search box is required, not decoration — see + * [MarketViewModel]. Results are listings, so a row names both the item and the shop + * that sells it, and tapping it opens that shop. + */ +@Composable +fun MarketScreen( + onOpenVendor: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: MarketViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val meta by viewModel.meta.collectAsStateWithLifecycle() + val query by viewModel.query.collectAsStateWithLifecycle() + + Column(modifier.fillMaxSize()) { + OutlinedTextField( + value = query, + onValueChange = viewModel::onQueryChange, + label = { Text(stringResource(R.string.market_search_label)) }, + singleLine = true, + // Searched on submit rather than per keystroke: this is the site's first + // rate-limited public endpoint. + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { viewModel.search() }), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + meta?.staleAt?.let { + SectionLabel( + text = stringResource(R.string.market_staleness), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + when (val s = state) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load) + is UiState.Success -> { + if (s.data.listings.isEmpty()) { + EmptyView(stringResource(R.string.market_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.listings, key = { it.serial ?: it.hashCode().toString() }) { listing -> + ListingCard(listing, onOpenVendor) + } + } + } + } + } + } +} + +@Composable +private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) { + val vendorSerial = listing.vendor?.serial + Card( + Modifier + .fillMaxWidth() + .then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier), + ) { + Column(Modifier.padding(16.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = listingTitle(listing) + ?: stringResource(R.string.market_unnamed_item, listing.itemId ?: 0), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.market_price, listing.price ?: 0L), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + } + val shop = listing.vendor?.shopName ?: listing.vendor?.ownerName + if (shop != null) { + Text( + text = shop, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + locationLine(listing.vendor?.location)?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * One shop and its stock. The only surface that can answer the two questions a result + * list can't: how much of a truncated shop is published, and where a shop is when the + * shard doesn't say. + */ +@Composable +fun MarketVendorScreen( + serial: String, + modifier: Modifier = Modifier, + viewModel: MarketVendorViewModel = hiltViewModel(), +) { + androidx.compose.runtime.LaunchedEffect(serial) { viewModel.load(serial) } + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val s = state) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier) + is UiState.Success -> VendorContent(s.data, modifier) + } +} + +@Composable +private fun VendorContent(vendor: MarketVendorDto, modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp), + ) { + item { + Column { + Text( + vendor.shopName ?: stringResource(R.string.market_unnamed_shop), + style = MaterialTheme.typography.titleLarge, + ) + vendor.ownerName?.let { + Text( + stringResource(R.string.market_owner, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + // A gated location is a real answer, not a blank: the shard has + // this shop, it just doesn't publish where it stands. + text = locationLine(vendor.location) ?: stringResource(R.string.market_location_hidden), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (vendor.truncated) { + Text( + text = stringResource( + R.string.market_truncated, + vendor.count ?: vendor.items.size, + vendor.total ?: 0, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } + if (vendor.items.isEmpty()) { + item { Text(stringResource(R.string.market_shop_empty), style = MaterialTheme.typography.bodyMedium) } + } else { + items(vendor.items, key = { it.serial ?: it.hashCode().toString() }) { item -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = listingTitle(item) ?: stringResource(R.string.market_unnamed_item, item.itemId ?: 0), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.market_price, item.price ?: 0L), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +// ── Pure helpers (unit-tested) ─────────────────────────────────────────────── + +/** + * What to call a listing: a player-set name, else the server-resolved cliloc name, + * else null so the caller can fall back to the item id. A shard with no cliloc table + * configured legitimately publishes neither. + * + * A stack shows its count, since "12 × ingot" and "ingot" at the same price are very + * different offers. + */ +internal fun listingTitle(listing: MarketListingDto): String? { + val base = listing.label ?: return null + val amount = listing.amount ?: 1 + return if (amount > 1) "$amount × $base" else base +} + +/** + * A shop's whereabouts as one line, or null when the shard publishes no location — + * which happens both because an admin gated the field and because the nesting means + * the WHOLE block goes at once, never a half-populated one. + */ +internal fun locationLine(location: MarketLocationDto?): String? { + if (location == null) return null + val place = location.house ?: location.region + val facet = location.map + return when { + place != null && facet != null -> "$place, $facet" + place != null -> place + facet != null -> facet + else -> null + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/MarketViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/MarketViewModel.kt new file mode 100644 index 0000000..f3ded7c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/MarketViewModel.kt @@ -0,0 +1,118 @@ +/* + * 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.result.ApiResult +import com.runicgateway.app.data.api.dto.MarketMetaDto +import com.runicgateway.app.data.api.dto.MarketPageDto +import com.runicgateway.app.data.api.dto.MarketVendorDto +import com.runicgateway.app.data.repository.ShardRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toShardUiState +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 shard-wide player-vendor marketplace (PLAN.md §9 M11, `docs/link/v3.md` §8): + * search every shop's stock at once. + * + * **Not live, on purpose.** The `market` feature ships with its SSE fan-out disabled — + * a firehose of full vendor inventories would be the site's biggest bandwidth consumer + * and no screen needs it live — so this is a plain paginated read. It is also the + * first genuinely **rate-limited** public endpoint, which is why the query is applied + * on submit rather than on every keystroke. + * + * The staleness stamp from [meta] is not decoration: the shard sweeps vendors + * round-robin, so a listing can legitimately be a full cycle behind, and a screen that + * implied live prices would send someone to an item that sold twenty minutes ago. + */ +@HiltViewModel +class MarketViewModel @Inject constructor( + private val repository: ShardRepository, +) : ViewModel() { + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + private val _meta = MutableStateFlow(null) + val meta: StateFlow = _meta.asStateFlow() + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _sort = MutableStateFlow(ShardRepository.SORT_PRICE_ASC) + val sort: StateFlow = _sort.asStateFlow() + + init { + load() + } + + fun onQueryChange(value: String) { + // Bounded to what the server accepts, so an over-long query is trimmed here + // rather than bounced as a 400. + _query.value = value.take(MAX_QUERY) + } + + fun onSortChange(value: String) { + if (value == _sort.value) return + _sort.value = value + search() + } + + /** Run the current query. Called on submit, not per keystroke — this endpoint is rate-limited. */ + fun search() = load() + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + // Meta is secondary: the staleness banner and filter options are worth + // having, but a failure there must not blank the results. + _meta.value = (repository.marketMeta() as? ApiResult.Ok)?.data + _state.value = repository.market( + query = _query.value, + sort = _sort.value, + ).toShardUiState() + } + } + + companion object { + /** The server rejects a longer `q`. */ + const val MAX_QUERY = 60 + } +} + +/** + * One shop and its stock. The only surface that can render the two states a result + * list cannot: a [MarketVendorDto.truncated] shop, and a location an admin has gated + * away — which is a real answer ("the shard doesn't publish where this is") rather + * than an empty coordinate. + */ +@HiltViewModel +class MarketVendorViewModel @Inject constructor( + private val repository: ShardRepository, +) : ViewModel() { + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + private var serial: String? = null + + fun load(serial: String) { + this.serial = serial + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.marketVendor(serial).toShardUiState() + } + } + + fun retry() { + serial?.let { load(it) } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt new file mode 100644 index 0000000..53a7225 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.shard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.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.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.RulesetCapsDto +import com.runicgateway.app.data.api.dto.RulesetDto +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 +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.StatusPill + +/** + * The shard ruleset (PLAN.md §9 M11): what this world is configured to do. + * + * Every block renders only when the shard published it — an omitted block means the + * system is off, not that the value is unknown, so an empty section would assert + * something false. + */ +@Composable +fun RulesScreen( + modifier: Modifier = Modifier, + viewModel: RulesViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val s = state) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Success -> { + val ruleset = s.data + // A null body is a successful read of a shard that has never published its + // ruleset — distinct from the feature being switched off, which is an error + // state above. + if (ruleset == null) { + EmptyView(stringResource(R.string.rules_unpublished), modifier) + } else { + RulesetContent(ruleset, modifier) + } + } + } +} + +@Composable +private fun RulesetContent(ruleset: RulesetDto, 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 { + RuleCard(stringResource(R.string.rules_section_shard)) { + RuleRow(stringResource(R.string.rules_name), ruleset.shard) + RuleRow(stringResource(R.string.rules_expansion), ruleset.expansion) + // `connect` is the ruleset's one admin-configurable field: an operator + // who published an address may still want it behind a login, so its + // absence here is a setting, not a missing value. + RuleRow(stringResource(R.string.rules_connect), ruleset.connect) + } + } + if (ruleset.systems.isNotEmpty()) { + item { SystemsCard(ruleset.systems) } + } + ruleset.caps?.let { caps -> item { CapsCard(caps) } } + item { + val accounts = ruleset.accounts + val housing = ruleset.housing + if (accounts != null || housing != null) { + RuleCard(stringResource(R.string.rules_section_accounts)) { + RuleRow(stringResource(R.string.rules_char_slots), accounts?.charSlots?.toString()) + RuleRow(stringResource(R.string.rules_per_ip), accounts?.perIp?.toString()) + RuleRow( + stringResource(R.string.rules_house_limit), + housing?.accountHouseLimit?.toString(), + ) + } + } + } + ruleset.vendors?.let { vendors -> + item { + RuleCard(stringResource(R.string.rules_section_vendors)) { + RuleRow( + stringResource(R.string.rules_restock_delay), + vendors.restockDelayMinutes?.let { stringResource(R.string.rules_minutes, it) }, + ) + RuleRow(stringResource(R.string.rules_max_sell), vendors.maxSell?.toString()) + } + } + } + ruleset.schedule?.let { schedule -> + item { + RuleCard(stringResource(R.string.rules_section_schedule)) { + RuleRow( + stringResource(R.string.rules_autosave), + schedule.autoSaveFrequencyMinutes?.let { stringResource(R.string.rules_minutes, it) }, + ) + RuleRow( + stringResource(R.string.rules_autorestart), + formatRestart( + schedule.autoRestartEnabled, + schedule.autoRestartHour, + schedule.autoRestartMinute, + ), + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SystemsCard(systems: Map) { + RuleCard(stringResource(R.string.rules_section_systems)) { + FlowRow( + Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Sorted so the list is stable across reloads; the wire order is a config + // read order and carries no meaning. + systems.entries.sortedBy { it.key }.forEach { (key, on) -> + StatusPill( + text = humaniseSystem(key), + tone = if (on) PillTone.Success else PillTone.Neutral, + ) + } + } + } +} + +@Composable +private fun CapsCard(caps: RulesetCapsDto) { + RuleCard(stringResource(R.string.rules_section_caps)) { + // Skill caps arrive in TENTHS (1000 = 100.0). Showing the raw number would read + // as a shard with ten times the usual limit. + RuleRow(stringResource(R.string.rules_skill_cap), caps.skillCap?.let { formatSkillCap(it) }) + RuleRow(stringResource(R.string.rules_total_skill_cap), caps.totalSkillCap?.let { formatSkillCap(it) }) + RuleRow(stringResource(R.string.rules_stat_cap), caps.stat?.toString()) + RuleRow(stringResource(R.string.rules_str_cap), caps.str?.toString()) + RuleRow(stringResource(R.string.rules_dex_cap), caps.dex?.toString()) + RuleRow(stringResource(R.string.rules_int_cap), caps.int?.toString()) + } +} + +@Composable +private fun RuleCard(title: String, content: @Composable () -> Unit) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +/** One label/value line. Renders nothing when the shard published no value. */ +@Composable +private fun RuleRow(label: String, value: String?) { + if (value.isNullOrBlank()) return + Row( + Modifier.fillMaxWidth().padding(top = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +// ── Pure helpers (unit-tested) ─────────────────────────────────────────────── + +/** `cityLoyalty` → "City loyalty". The systems block is a flat bag of config keys. */ +internal fun humaniseSystem(key: String): String = key + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replaceFirstChar { it.uppercaseChar() } + +/** A skill cap already converted out of tenths: drop the ".0" on whole values. */ +internal fun formatSkillCap(value: Double): String = + if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value) + +/** The auto-restart schedule, or null when the shard doesn't run one. */ +internal fun formatRestart(enabled: Boolean?, hour: Int?, minute: Int?): String? { + if (enabled != true) return null + if (hour == null) return null + return "%02d:%02d".format(hour, minute ?: 0) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/RulesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/shard/RulesViewModel.kt new file mode 100644 index 0000000..da78e0c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/shard/RulesViewModel.kt @@ -0,0 +1,63 @@ +/* + * 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.data.api.dto.RulesetDto +import com.runicgateway.app.data.repository.ShardRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toShardUiState +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 shard ruleset (PLAN.md §9 M11, `docs/link/v3.md` §5): what this world is + * configured to do — systems on/off, caps, account and housing limits, the champion + * and Felucca tables, the save/restart schedule. + * + * Two states the screen must tell apart, which is why the success type is nullable: + * a `null` body means the shard has **never published** a ruleset (the plugin is old, + * or `RulesetEnabled=false`), while the feature being switched off is a 404 folded + * into `ErrorKind.FEATURE_UNAVAILABLE`. + * + * Kept live by the `world.ruleset` frame, which the shard re-emits on every sidecar + * reconnect — so a shard that restarts with edited config updates the open screen. + */ +@HiltViewModel +class RulesViewModel @Inject constructor( + private val repository: ShardRepository, +) : ViewModel() { + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + collectLive() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.ruleset().toShardUiState() + } + } + + private fun collectLive() { + viewModelScope.launch { + repository.liveEvents().collect { event -> + if (event !is ShardStreamEvent.Frame || event.kind != "world.ruleset") return@collect + // The frame IS the whole ruleset — replace rather than merge. A frame the + // app can't decode is skipped, leaving the loaded copy in place. + repository.rulesetFrame(event.data)?.let { _state.value = UiState.Success(it) } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d104426..17e3773 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,10 @@ News Wiki Shard + Rules + Atlas + Leaderboards + Market About Contact My account @@ -382,6 +386,74 @@ Led by %1$s Alliance: %1$s + + + This shard hasn\'t published its ruleset yet. + Shard + Systems + Skill & stat caps + Accounts & housing + Vendors + Saves & restarts + Name + Expansion + Connect + Individual skill cap + Total skill cap + Total stat cap + Strength cap + Dexterity cap + Intelligence cap + Character slots + Accounts per IP + Houses per account + Vendor restock delay + Max sell quantity + Auto-save every + Auto-restart at + %1$d min + + + This shard isn\'t publishing any leaderboards yet. + Nobody has scored here yet. + %1$d players + + Cap: %1$d + #%1$d %2$s + + Someone + + + Search every shop + No listings match that search. + This shop has nothing for sale. + + Prices are refreshed in rotation and may be out of date. + %1$d gp + Item %1$d + A shop + Kept by %1$s + + This shard doesn\'t publish shop locations. + Showing %1$d of %2$d — this shop holds more than the shard publishes. + + + Search creatures + No creatures match that search. + %1$d spawners + Up to %1$d alive at once + %1$s (%2$d) + Spawn points + Also spawns here + More spawn points than shown. + Up to %1$d + + Respawn %1$s + No governors — this shard may not run the City Loyalty system. Governed by %1$s diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt index 75da7db..6cb7479 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt @@ -18,6 +18,14 @@ 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.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto +import com.runicgateway.app.data.api.dto.AtlasMetaDto +import com.runicgateway.app.data.api.dto.MarketMetaDto +import com.runicgateway.app.data.api.dto.MarketPageDto +import com.runicgateway.app.data.api.dto.MarketVendorDto +import com.runicgateway.app.data.api.dto.PointsBoardDto +import com.runicgateway.app.data.api.dto.RulesetDto import com.runicgateway.app.data.api.dto.ShardFeaturesDto import com.runicgateway.app.data.api.dto.ShardStatusDto import com.runicgateway.app.data.api.dto.StatusDto @@ -59,6 +67,24 @@ class FakePublicApi : PublicApi { var houses: List = emptyList() var shardFeatures: ShardFeaturesDto = ShardFeaturesDto() + // Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the + // shard has never published one, which is a success, not a failure. + var ruleset: RulesetDto? = null + var pointsBoards: List = emptyList() + var pointsBoard: PointsBoardDto = PointsBoardDto() + var market: MarketPageDto = MarketPageDto() + var marketMeta: MarketMetaDto = MarketMetaDto() + var marketVendor: MarketVendorDto = MarketVendorDto() + var atlasCreatures: AtlasCreaturePageDto = AtlasCreaturePageDto() + var atlasCreature: AtlasCreatureDto = AtlasCreatureDto() + var atlasMeta: AtlasMetaDto = AtlasMetaDto() + + /** Last market query seen, so a test can assert blanks were dropped. */ + var lastMarketQuery: String? = null + + /** Last atlas facet filter seen. */ + var lastAtlasFacet: String? = null + /** Last contact request body seen (so a test can assert it was trimmed/forwarded). */ var lastContact: ContactRequest? = null @@ -87,6 +113,40 @@ class FakePublicApi : PublicApi { } override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures) + override suspend fun getShardRuleset(): RulesetDto? = reply(ruleset) + override suspend fun getShardPoints(): List = reply(pointsBoards) + override suspend fun getShardPointsBoard(system: String): PointsBoardDto = reply(pointsBoard) + + override suspend fun getShardMarket( + query: String?, + minPrice: Long?, + maxPrice: Long?, + map: String?, + region: String?, + sort: String?, + limit: Int?, + offset: Int?, + ): MarketPageDto { + lastMarketQuery = query + return reply(market) + } + + override suspend fun getShardMarketMeta(): MarketMetaDto = reply(marketMeta) + override suspend fun getShardMarketVendor(serial: String, limit: Int?, offset: Int?): MarketVendorDto = + reply(marketVendor) + + override suspend fun getAtlasCreatures( + query: String?, + facet: String?, + limit: Int?, + offset: Int?, + ): AtlasCreaturePageDto { + lastAtlasFacet = facet + return reply(atlasCreatures) + } + + override suspend fun getAtlasCreature(slug: String): AtlasCreatureDto = reply(atlasCreature) + override suspend fun getAtlasMeta(): AtlasMetaDto = reply(atlasMeta) override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus) override suspend fun getShardFeed(kind: String?, limit: Int?): List = reply(shardFeed) override suspend fun getShardEconomy(limit: Int?): List = reply(shardEconomy) diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt new file mode 100644 index 0000000..a05a2d9 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt @@ -0,0 +1,172 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.shard + +import com.runicgateway.app.data.api.dto.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasSpawnerDto +import com.runicgateway.app.data.api.dto.MarketListingDto +import com.runicgateway.app.data.api.dto.MarketLocationDto +import com.runicgateway.app.data.api.dto.PointsBoardDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The pure display helpers behind the four Protocol 3.0 screens (PLAN.md §9 M11). + * Each one exists because the raw wire value would be wrong or misleading on screen — + * units in tenths, delays in seconds, an "uncapped" cap of zero, a gated field. + */ +class ShardContentHelpersTest { + + // ── Rules (§5) ─────────────────────────────────────────────────────── + + @Test fun skillCapsConvertOutOfTenths() { + // 1000 is 100.0. Showing the raw number reads as a shard with ten times the + // usual limit, which is worse than showing nothing. + assertEquals("100", formatSkillCap(1000 / 10.0)) + assertEquals("72.5", formatSkillCap(725 / 10.0)) + } + + @Test fun systemKeysHumanise() { + // Word boundaries become spaces and the inner capital is kept, matching the + // website's `humanise` — "City Loyalty" is the system's actual name. + assertEquals("City Loyalty", humaniseSystem("cityLoyalty")) + assertEquals("Vvv", humaniseSystem("vvv")) + assertEquals("Treasure Maps", humaniseSystem("treasureMaps")) + } + + @Test fun theRestartScheduleOnlyShowsWhenTheShardRunsOne() { + assertEquals("04:30", formatRestart(enabled = true, hour = 4, minute = 30)) + assertEquals("04:00", formatRestart(enabled = true, hour = 4, minute = null)) + assertNull(formatRestart(enabled = false, hour = 4, minute = 30)) + assertNull(formatRestart(enabled = true, hour = null, minute = 30)) + } + + // ── Leaderboards (§7) ──────────────────────────────────────────────── + + @Test fun boardLabelFallsBackToTheHumanisedKey() { + // The PRIMARY path: four of five boards on a real shard name themselves with a + // cliloc and send nameString null. + assertEquals("Queens Loyalty", boardLabel(PointsBoardDto(system = "QueensLoyalty"))) + assertEquals( + "Queen's Loyalty", + boardLabel(PointsBoardDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")), + ) + assertEquals("Clean Up Britannia", boardLabel(PointsBoardDto(system = "CleanUpBritannia", nameString = " "))) + } + + @Test fun anUncappedBoardReportsNoCap() { + assertNull(PointsBoardDto(maxPoints = 0).cap) + assertEquals(30000L, PointsBoardDto(maxPoints = 30000).cap) + } + + @Test fun boardsOrderByContestedThenName() { + val boards = listOf( + PointsBoardDto(system = "Quiet", players = 2), + PointsBoardDto(system = "Busy", players = 900), + PointsBoardDto(system = "AlsoQuiet", players = 2), + ) + assertEquals(listOf("Busy", "Also Quiet", "Quiet"), orderBoards(boards).map { boardLabel(it) }) + } + + @Test fun boardsTheShardHidesFromItsOwnGumpAreDropped() { + // `showOnGump` is the shard's own "is this player-facing?" signal. + val boards = listOf( + PointsBoardDto(system = "Shown", players = 1, showOnGump = true), + PointsBoardDto(system = "Internal", players = 99, showOnGump = false), + ) + assertEquals(listOf("Shown"), orderBoards(boards).map { it.system }) + } + + // ── Market (§8) ────────────────────────────────────────────────────── + + @Test fun listingTitlePrefersAPlayerNameThenTheResolvedOne() { + assertEquals("Bob's axe", listingTitle(MarketListingDto(name = "Bob's axe", displayName = "hatchet"))) + assertEquals("hatchet", listingTitle(MarketListingDto(displayName = "hatchet"))) + } + + @Test fun listingTitleIsNullWithoutAnyName() { + // A shard with no cliloc table configured publishes neither, and the screen + // falls back to the item id rather than inventing a label. + assertNull(listingTitle(MarketListingDto(itemId = 3922))) + } + + @Test fun aStackShowsItsCount() { + // "12 × ingot" and "ingot" at the same price are very different offers. + assertEquals("12 × ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = 12))) + assertEquals("ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = 1))) + assertEquals("ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = null))) + } + + @Test fun locationPrefersTheHouseThenTheRegion() { + assertEquals( + "Darrow's Tower, Felucca", + locationLine(MarketLocationDto(map = "Felucca", region = "Britain", house = "Darrow's Tower")), + ) + assertEquals("Britain, Felucca", locationLine(MarketLocationDto(map = "Felucca", region = "Britain"))) + assertEquals("Felucca", locationLine(MarketLocationDto(map = "Felucca"))) + } + + @Test fun aGatedLocationIsNullRatherThanAHalfAnswer() { + // The block is nested precisely so one admin rule takes the facet, the + // coordinates, the region and the house together — there is no partial state + // to render. + assertNull(locationLine(null)) + assertNull(locationLine(MarketLocationDto(x = 100, y = 200))) + } + + // ── Atlas (§6) ─────────────────────────────────────────────────────── + + @Test fun respawnDelaysAreReadAsSeconds() { + // The API normalises XmlSpawner's mixed minutes/seconds, so these ARE seconds. + assertEquals("30s", formatRespawn(30, 30)) + assertEquals("5m", formatRespawn(300, 300)) + assertEquals("5m–10m", formatRespawn(300, 600)) + assertEquals("1m 30s", formatRespawn(90, 90)) + } + + @Test fun aHalfSpecifiedRespawnStillReads() { + assertEquals("5m", formatRespawn(300, null)) + assertEquals("5m", formatRespawn(null, 300)) + assertNull(formatRespawn(null, null)) + } + + @Test fun spawnerPlacePrefersTheServersPlacementLabel() { + // The point-in-rect transform is the reason this feature exists: it turns + // "5411,1234" into "Despise, Felucca". + val spawner = AtlasSpawnerDto( + label = "Despise, Felucca", + region = "Despise", + facet = "Felucca", + x = 5411, + y = 1234, + ) + assertEquals("Despise, Felucca", spawnerPlace(spawner)) + } + + @Test fun spawnerPlaceFallsBackThroughRegionLandmarkThenCoordinates() { + assertEquals( + "Despise, Felucca", + spawnerPlace(AtlasSpawnerDto(region = "Despise", facet = "Felucca")), + ) + assertEquals( + "Yew Crossroads, Trammel", + spawnerPlace(AtlasSpawnerDto(landmark = "Yew Crossroads", facet = "Trammel")), + ) + // ~17% of stock spawns resolve to no named place; coordinates are honest there. + assertEquals( + "Felucca 5411, 1234", + spawnerPlace(AtlasSpawnerDto(facet = "Felucca", x = 5411, y = 1234)), + ) + } + + @Test fun facetSummaryLeadsWithWhereItMostlyIs() { + val creature = AtlasCreatureDto( + slug = "lizardman", + facets = mapOf("Trammel" to 4, "Felucca" to 30, "Ilshenar" to 12), + ) + assertEquals("Felucca, Ilshenar, Trammel", facetSummary(creature)) + assertNull(facetSummary(AtlasCreatureDto(slug = "unique"))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentViewModelTest.kt new file mode 100644 index 0000000..a8eab2f --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentViewModelTest.kt @@ -0,0 +1,223 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.shard + +import com.runicgateway.app.core.net.ShardStreamEvent +import com.runicgateway.app.data.api.dto.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto +import com.runicgateway.app.data.api.dto.MarketListingDto +import com.runicgateway.app.data.api.dto.MarketPageDto +import com.runicgateway.app.data.api.dto.MarketVendorDto +import com.runicgateway.app.data.api.dto.PointsBoardDto +import com.runicgateway.app.data.api.dto.RulesetDto +import com.runicgateway.app.data.api.fake.FakePublicApi +import com.runicgateway.app.data.api.fake.FakeShardStream +import com.runicgateway.app.data.repository.ShardRepository +import com.runicgateway.app.ui.ErrorKind +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.httpError +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * The four Protocol 3.0 content screens (PLAN.md §9 M11): loading, the live merge, and + * the states that are easy to get wrong — "published nothing" vs "switched off", and a + * gated feature reading as unavailable rather than as a fault. + */ +class ShardContentViewModelTest { + + @get:Rule val mainDispatcherRule = MainDispatcherRule() + + private val api = FakePublicApi() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json) + + // ── Rules ──────────────────────────────────────────────────────────── + + @Test fun rulesLoadTheRuleset() { + api.ruleset = RulesetDto(shard = "UOMysticmoon", expansion = "EJ") + + val state = RulesViewModel(repo()).state.value + + assertEquals("UOMysticmoon", (state as UiState.Success).data?.shard) + } + + @Test fun anUnpublishedRulesetIsASuccessWithNoBody() { + // Distinct from the feature being switched off: the shard is reachable and + // simply hasn't emitted world.ruleset yet. + api.ruleset = null + + val state = RulesViewModel(repo()).state.value + + assertTrue(state is UiState.Success) + assertNull((state as UiState.Success).data) + } + + @Test fun aGatedRulesetFeatureReadsAsUnavailableNotAsAFault() { + api.error = httpError(404) + + val state = RulesViewModel(repo()).state.value + + assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind) + } + + @Test fun aLiveRulesetFrameReplacesTheLoadedCopy() { + // The frame IS the whole ruleset — the shard re-emits it on every reconnect, so + // a restart with edited config updates an open screen. + api.ruleset = RulesetDto(shard = "Old", expansion = "EJ") + val stream = FakeShardStream( + listOf( + ShardStreamEvent.Frame( + "world.ruleset", + buildJsonObject { put("shard", "New"); put("expansion", "EJ") }, + ), + ), + ) + + val state = RulesViewModel(repo(stream)).state.value + + assertEquals("New", (state as UiState.Success).data?.shard) + } + + // ── Leaderboards ───────────────────────────────────────────────────── + + @Test fun leaderboardsSeedAndMergeLiveBoards() { + api.pointsBoards = listOf( + PointsBoardDto(system = "QueensLoyalty", players = 800), + PointsBoardDto(system = "VoidPool", players = 10), + ) + val stream = FakeShardStream( + listOf( + ShardStreamEvent.Frame( + "points.board", + buildJsonObject { put("system", "VoidPool"); put("players", 999) }, + ), + ), + ) + + val boards = (LeaderboardsViewModel(repo(stream)).state.value as UiState.Success).data + + // The merged board overtook the seeded one on the contested ordering. + assertEquals(listOf("VoidPool", "QueensLoyalty"), boards.map { it.system }) + } + + @Test fun aGatedLeaderboardsFeatureReadsAsUnavailable() { + api.error = httpError(403) + + val state = LeaderboardsViewModel(repo()).state.value + + assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind) + } + + // ── Market ─────────────────────────────────────────────────────────── + + @Test fun marketLoadsListingsAndMeta() { + api.market = MarketPageDto( + listings = listOf(MarketListingDto(serial = "0x1", displayName = "hatchet", price = 250)), + total = 1, + ) + + val vm = MarketViewModel(repo()) + + assertEquals(1, (vm.state.value as UiState.Success).data.listings.size) + } + + @Test fun aBlankMarketQueryIsNotSentAsAnEmptyFilter() { + MarketViewModel(repo()) + assertNull(api.lastMarketQuery) + } + + @Test fun theMarketQueryIsBoundedToWhatTheServerAccepts() { + // Trimmed here rather than bounced as a 400. + val vm = MarketViewModel(repo()) + vm.onQueryChange("x".repeat(200)) + assertEquals(MarketViewModel.MAX_QUERY, vm.query.value.length) + } + + @Test fun aFailedMetaLookupDoesNotBlankTheResults() { + // Meta drives the staleness banner and the filter options; it is secondary. + api.market = MarketPageDto(listings = listOf(MarketListingDto(serial = "0x1")), total = 1) + + val vm = MarketViewModel(repo()) + + assertTrue(vm.state.value is UiState.Success) + } + + @Test fun aVendorLoadsBySerialAndKeepsItForRetry() { + api.marketVendor = MarketVendorDto(serial = "0x40001234", shopName = "Darrow's Wares", truncated = true) + + val vm = MarketVendorViewModel(repo()) + vm.load("0x40001234") + + val vendor = (vm.state.value as UiState.Success).data + assertEquals("Darrow's Wares", vendor.shopName) + assertTrue(vendor.truncated) + + // Retry re-uses the serial rather than needing it passed again. + api.error = httpError(500) + vm.retry() + assertTrue(vm.state.value is UiState.Error) + } + + // ── Atlas ──────────────────────────────────────────────────────────── + + @Test fun atlasLoadsCreaturesAndDiscoversTheShardsFacets() { + // Nothing may NAME a facet — a shard can add, replace or rename them, so the + // filter options come from the shard's own data. + api.atlasCreatures = AtlasCreaturePageDto( + creatures = listOf( + AtlasCreatureDto(slug = "lizardman", facets = mapOf("Felucca" to 30, "Sosaria" to 2)), + AtlasCreatureDto(slug = "orc", facets = mapOf("Underdark" to 5)), + ), + total = 2, + ) + + val vm = AtlasViewModel(repo()) + + assertEquals(2, (vm.state.value as UiState.Success).data.creatures.size) + assertEquals(listOf("Felucca", "Sosaria", "Underdark"), vm.facets.value) + } + + @Test fun aFilteredPageDoesNotNarrowTheFacetOptions() { + api.atlasCreatures = AtlasCreaturePageDto( + creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1, "Trammel" to 1))), + ) + val vm = AtlasViewModel(repo()) + assertEquals(listOf("Felucca", "Trammel"), vm.facets.value) + + // Filtering to one facet must not leave the picker with only that option. + api.atlasCreatures = AtlasCreaturePageDto( + creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1))), + ) + vm.onFacetChange("Felucca") + + assertEquals(listOf("Felucca", "Trammel"), vm.facets.value) + assertEquals("Felucca", api.lastAtlasFacet) + } + + @Test fun aGatedAtlasReadsAsUnavailable() { + api.error = httpError(404) + + val state = AtlasViewModel(repo()).state.value + + assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind) + } + + @Test fun aCreatureLoadsBySlug() { + api.atlasCreature = AtlasCreatureDto(slug = "lizardman", name = "Lizardman", total = 214) + + val vm = AtlasCreatureViewModel(repo()) + vm.load("lizardman") + + assertEquals(214, (vm.state.value as UiState.Success).data.total) + } +} -- 2.49.1 From 4f85021be23e9b7ec9c8a2787a5130b7d1aa350c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 1 Aug 2026 00:59:23 -0500 Subject: [PATCH 3/3] fix(shard): decode the atlas `places` objects and render them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AtlasCreatureDto.places` was typed `List` while the server sends `{facet, label, spawners, maxAlive}` objects. The detail route answers 200 with ~49 KB, kotlinx throws on decode, and the screen renders "Something went wrong on the server" — so the whole Atlas creature page was dead, and the error blamed a server that was fine. Nullable-with-defaults protects against a missing field, never a wrong element type. Adds AtlasPlaceDto, plus the `art` field the server also sends, so a decode cannot depend on that staying absent (neither client renders art yet). `places` was never rendered either, so the aggregate the atlas exists to give — "Shrines, Isamu-Jima, Yew", resolved server-side by point-in-rect — was missing from the app while the web page led with it. Adds a "Where it spawns" section above the individual spawners, matching web's ordering, and a plural for the spawner count now that single-spawner places are on screen in bulk. Adds ShardContentDtoTest — the first decode test any of the four Protocol 3.0 DTOs has had, fed payloads captured from a live server. That absence is the root cause: the fakes in data/api/fake/ construct DTOs in Kotlin, so no test in the suite could see a wire mismatch, even though PLAN.md §9 already required "DTO decode for each new shape". Also renders a placeholder row on an unscored leaderboard (the instance name, em dash where a score goes) rather than a blank card — deliberately not shaped like a real entry, since a placeholder that looked like a standing would be a fabricated one. Found by the on-device five-rung walk against a live shard; all four screens re-verified on the emulator afterwards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP --- .../app/data/api/dto/ShardContentDto.kt | 32 +++- .../java/com/runicgateway/app/ui/RunicApp.kt | 2 +- .../runicgateway/app/ui/shard/AtlasScreen.kt | 41 ++++- .../app/ui/shard/LeaderboardsScreen.kt | 44 ++++- app/src/main/res/values/strings.xml | 13 +- .../app/data/api/dto/ShardContentDtoTest.kt | 153 ++++++++++++++++++ .../app/ui/shard/ShardContentHelpersTest.kt | 12 ++ 7 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/ShardContentDtoTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt index 0999f84..6f35cbc 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/ShardContentDto.kt @@ -287,14 +287,42 @@ data class AtlasCreatureDto( val points: Int? = null, /** Spawner count per facet. */ val facets: Map = emptyMap(), - /** Region/landmark names where it appears — the detail route only. */ - val places: List = emptyList(), + /** + * Where it appears, aggregated per named place — the detail route only, and the + * answer the whole screen exists to give. **Objects, not strings:** the server + * sends `{facet, label, spawners, maxAlive}`, and typing this `List` + * made the detail route fail to decode entirely. + */ + val places: List = emptyList(), + /** + * Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is + * the normal state, since no artwork ships. Neither client renders it yet; the + * field is carried so a decode never depends on that staying true. + */ + val art: String? = null, val spawners: List = emptyList(), val spawnersTruncated: Boolean = false, /** Creatures sharing its spawners — the detail route only. */ val alsoHere: List = emptyList(), ) +/** + * One named place a creature spawns in, already aggregated across its spawners. + * + * [label] is the server's point-in-rect resolution of raw coordinates ("Shrines", + * "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally + * "Wilderness" — turning a list of coordinates into an answer. + */ +@Serializable +data class AtlasPlaceDto( + val facet: String? = null, + val label: String? = null, + /** Spawners in this place. */ + val spawners: Int? = null, + /** How many can be alive at once here, summed across those spawners. */ + val maxAlive: Int? = null, +) + /** * One spawn point. * 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 532a7f6..ddcc9a0 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -319,7 +319,7 @@ private fun RunicNavHost( // here" from its own 404/403, so a deep link to a gated feature still lands on // an honest answer even though the menu hides the entry. composable(Routes.SHARD_RULES) { RulesScreen() } - composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen() } + composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) } composable(Routes.SHARD_MARKET) { MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) }) } diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt index 8ceb220..0619dd2 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -32,6 +33,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasPlaceDto import com.runicgateway.app.data.api.dto.AtlasSpawnerDto import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView @@ -111,7 +113,7 @@ private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> // and only the detail route sends it. creature.points?.let { Text( - text = stringResource(R.string.atlas_spawner_count, it), + text = pluralStringResource(R.plurals.atlas_spawner_count, it, it), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -178,6 +180,17 @@ fun AtlasCreatureScreen( } } } + // The aggregate comes first: "where is it" is the question, and the + // individual coordinates below are the follow-up. Same ordering as web. + if (creature.places.isNotEmpty()) { + item { SectionLabel(stringResource(R.string.atlas_section_places)) } + items( + creature.places, + key = { "${it.facet.orEmpty()}:${it.label.orEmpty()}" }, + ) { place -> + PlaceRow(place) + } + } if (creature.spawners.isNotEmpty()) { item { SectionLabel(stringResource(R.string.atlas_section_spawners)) } items(creature.spawners, key = { it.id ?: it.hashCode().toLong() }) { spawner -> @@ -208,6 +221,24 @@ fun AtlasCreatureScreen( } } +@Composable +private fun PlaceRow(place: AtlasPlaceDto) { + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + text = placeLabel(place), + style = MaterialTheme.typography.bodyMedium, + ) + val meta = listOfNotNull( + place.facet, + place.spawners?.let { pluralStringResource(R.plurals.atlas_spawner_count, it, it) }, + place.maxAlive?.let { stringResource(R.string.atlas_place_max_alive, it) }, + ).joinToString(" · ") + if (meta.isNotBlank()) { + Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + @Composable private fun SpawnerRow(spawner: AtlasSpawnerDto) { Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { @@ -250,6 +281,14 @@ internal fun spawnerPlace(spawner: AtlasSpawnerDto): String { } } +/** + * The name of an aggregated place. [AtlasPlaceDto.label] is already the server's + * resolved answer and falls back to "Wilderness" there, so the only case left here is + * a place that carried no label at all — then the facet is better than nothing. + */ +internal fun placeLabel(place: AtlasPlaceDto): String = + place.label?.takeIf { it.isNotBlank() } ?: place.facet.orEmpty() + /** * A creature's facets as one line, most spawners first — "where is it *mostly*" is the * question a search result answers. diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt index bf22614..dc6cb14 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt @@ -22,6 +22,7 @@ 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.BrandDto import com.runicgateway.app.data.api.dto.PointsBoardDto import com.runicgateway.app.data.api.dto.PointsEntryDto import com.runicgateway.app.ui.components.SectionLabel @@ -32,6 +33,7 @@ import com.runicgateway.app.ui.components.SectionLabel */ @Composable fun LeaderboardsScreen( + brand: BrandDto? = null, modifier: Modifier = Modifier, viewModel: LeaderboardsViewModel = hiltViewModel(), ) { @@ -45,11 +47,11 @@ fun LeaderboardsScreen( onRetry = viewModel::load, key = { it.system.orEmpty() }, modifier = modifier, - ) { board -> BoardCard(board) } + ) { board -> BoardCard(board, placeholderName(brand)) } } @Composable -private fun BoardCard(board: PointsBoardDto) { +private fun BoardCard(board: PointsBoardDto, placeholderName: String) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -75,9 +77,33 @@ private fun BoardCard(board: PointsBoardDto) { } if (board.top.isEmpty()) { + // A board nobody has scored on still gets a row, so the page reads as a + // set of standings waiting to be filled rather than a stack of blanks. + // It is deliberately NOT shaped like an entry — no rank, no score, the + // instance's own name — because a placeholder that looked like a real + // standing would be a fabricated one. The first real entry replaces it. + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Row( + Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = placeholderName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.leaderboards_no_score), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( stringResource(R.string.leaderboards_board_empty), - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), ) @@ -129,3 +155,15 @@ internal fun boardLabel(board: PointsBoardDto): String { .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") .replaceFirstChar { it.uppercaseChar() } } + +/** + * The name to stand in for an empty board: this instance's, falling back to the app + * name — the same resolution the app bar uses, so a shard that publishes no branding + * still reads as *something* rather than as a blank row. + * + * Pure and separate so the fallback order is testable; [BrandDto.name] can be present + * but blank, which is a shard that set the key and left it empty. + */ +@Composable +internal fun placeholderName(brand: BrandDto?): String = + brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17e3773..5317643 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -417,6 +417,9 @@ This shard isn\'t publishing any leaderboards yet. Nobody has scored here yet. + + %1$d players Cap: %1$d @@ -444,9 +447,17 @@ Search creatures No creatures match that search. - %1$d spawners + + + %1$d spawner + %1$d spawners + Up to %1$d alive at once %1$s (%2$d) + + Where it spawns + up to %1$d at once Spawn points Also spawns here More spawn points than shown. diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardContentDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardContentDtoTest.kt new file mode 100644 index 0000000..ba2e864 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardContentDtoTest.kt @@ -0,0 +1,153 @@ +/* + * 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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Decode tests for the four Protocol 3.0 content DTOs, against payloads captured from a + * **live** server rather than hand-written to match the Kotlin types. + * + * These exist because the fakes in `data/api/fake/` construct DTOs directly, so no test + * in the suite ever fed one real JSON — and `AtlasCreatureDto.places` shipped typed + * `List` while the server sends objects. That decodes to an exception, the + * screen renders "something went wrong on the server", and 336 green tests say nothing. + * Nullable-with-defaults protects against a *missing* field, never a *wrong type*. + */ +class ShardContentDtoTest { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + // ── Spawn atlas (§6) ───────────────────────────────────────────────── + + /** Trimmed from `GET /api/v1/public/atlas/creatures/seaserpent` on a real shard. */ + private val seaSerpent = """ + {"slug":"seaserpent","name":"SeaSerpent","total":6048,"points":477, + "facets":{"Felucca":237,"Trammel":240},"art":"seaserpent.png", + "places":[{"facet":"Felucca","label":"Wilderness","spawners":91,"maxAlive":1194}, + {"facet":"Trammel","label":"Britain","spawners":12,"maxAlive":96}], + "spawners":[{"id":1617,"facet":"Felucca","name":"SeaLife#68","x":1691,"y":1623, + "width":350,"height":350,"range":175,"maxCount":15,"minDelay":300, + "maxDelay":600,"todStart":0,"todEnd":0,"todMode":0, + "region":"Britain","landmark":null,"label":"Britain"}], + "spawnersTruncated":true, + "alsoHere":[{"slug":"waterelemental","name":"WaterElemental","shared":242}]} + """.trimIndent() + + @Test fun atlasCreatureDetailDecodesTheRealPayload() { + val creature = json.decodeFromString(seaSerpent) + + assertEquals("seaserpent", creature.slug) + assertEquals(6048, creature.total) + assertEquals(477, creature.points) + assertEquals(240, creature.facets["Trammel"]) + assertTrue(creature.spawnersTruncated) + assertEquals("seaserpent.png", creature.art) + } + + @Test fun atlasPlacesAreObjectsNotStrings() { + // The regression. `places` is the aggregate the screen exists to show, and it + // arrives as {facet,label,spawners,maxAlive} — never as a bare place name. + val places = json.decodeFromString(seaSerpent).places + + assertEquals(2, places.size) + assertEquals("Wilderness", places[0].label) + assertEquals("Felucca", places[0].facet) + assertEquals(91, places[0].spawners) + assertEquals(1194, places[0].maxAlive) + } + + @Test fun atlasCreatureSurvivesAProjectedOrEmptyPayload() { + // The search route sends no `places`/`spawners`/`art`, and the visibility + // framework can drop any field from any of them. + val lean = json.decodeFromString("""{"slug":"orc"}""") + assertEquals("orc", lean.slug) + assertTrue(lean.places.isEmpty()) + assertTrue(lean.spawners.isEmpty()) + assertNull(lean.art) + + val bare = json.decodeFromString("""{"places":[{}]}""") + assertNull(bare.places[0].label) + assertNull(bare.places[0].spawners) + } + + // ── Market (§8) ────────────────────────────────────────────────────── + + @Test fun marketListingDecodesWithItsNestedVendorAndLocation() { + // `location` nests on the wire so one visibility rule covers map/x/y/region/house. + val listing = json.decodeFromString( + """{"serial":"0x40014A57","itemId":3937,"hue":1878,"amount":1,"price":115, + "name":null,"cliloc":1023937,"displayName":"longsword","child":false, + "vendor":{"serial":"0x2CB","shopName":"Seed Shop 225","ownerSerial":"0x201", + "ownerName":"Seed004A", + "location":{"map":"Felucca","x":1562,"y":1604,"z":0, + "region":"Britain","house":"Seed House 4"}}}""", + ) + + assertEquals("longsword", listing.displayName) + assertEquals(115L, listing.price) + assertEquals("Seed Shop 225", listing.vendor?.shopName) + assertEquals("Felucca", listing.vendor?.location?.map) + } + + @Test fun marketListingSurvivesTheFieldsAVisitorMayNotSee() { + // Below the `staff` rung the server omits ownerName/ownerSerial, and below + // `player` the whole nested location. Neither may break the decode. + val projected = json.decodeFromString( + """{"serial":"0x40014A57","price":115,"displayName":"longsword", + "vendor":{"serial":"0x2CB","shopName":"Seed Shop 225"}}""", + ) + + assertEquals("Seed Shop 225", projected.vendor?.shopName) + assertNull(projected.vendor?.ownerName) + assertNull(projected.vendor?.location) + } + + // ── Points boards (§7) ─────────────────────────────────────────────── + + @Test fun pointsBoardDecodesAnEmptyBoardAndItsCap() { + // A shard with nothing scored yet is the common case, not an error, and + // maxPoints 0 is the "uncapped" sentinel rather than a cap of zero. + val board = json.decodeFromString( + """{"kind":"points.board","system":"QueensLoyalty","nameNumber":1095163, + "nameString":null,"players":0,"maxPoints":15000,"showOnGump":true, + "top":[],"t":1785556444154,"updatedAt":"2026-08-01T03:54:04.000Z"}""", + ) + + assertEquals("QueensLoyalty", board.system) + assertEquals(15000L, board.maxPoints) + assertNull(board.nameString) + assertTrue(board.top.isEmpty()) + } + + // ── Ruleset (§5) ───────────────────────────────────────────────────── + + @Test fun rulesetDecodesTheNestedSectionsAndTolerantlySkipsUnknownOnes() { + // The frame is built from an allowlist that grows with the shard's config; a + // key this client has never heard of must not break the rules page. + val ruleset = json.decodeFromString( + """{"kind":"world.ruleset","shard":"My Shard","expansion":"EJ", + "caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125}, + "systems":{"factions":false,"vvv":true,"siege":false}, + "accounts":{"charSlots":7,"perIp":3}, + "somethingAddedLater":{"nested":true}}""", + ) + + assertEquals("My Shard", ruleset.shard) + assertEquals("EJ", ruleset.expansion) + // Caps arrive in tenths; the DTO's computed property is what the screen shows. + assertEquals(7000, ruleset.caps?.totalSkill) + assertEquals(700.0, ruleset.caps?.totalSkillCap!!, 0.0) + assertEquals(false, ruleset.systems["factions"]) + assertEquals(true, ruleset.systems["vvv"]) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt index a05a2d9..0f17772 100644 --- a/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/shard/ShardContentHelpersTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.shard import com.runicgateway.app.data.api.dto.AtlasCreatureDto +import com.runicgateway.app.data.api.dto.AtlasPlaceDto import com.runicgateway.app.data.api.dto.AtlasSpawnerDto import com.runicgateway.app.data.api.dto.MarketListingDto import com.runicgateway.app.data.api.dto.MarketLocationDto @@ -161,6 +162,17 @@ class ShardContentHelpersTest { ) } + @Test fun placeLabelUsesTheServersResolvedNameAndFallsBackToTheFacet() { + assertEquals( + "Isamu-Jima", + placeLabel(AtlasPlaceDto(facet = "Tokuno", label = "Isamu-Jima", spawners = 4)), + ) + // The server already falls back to "Wilderness", so a label-less place is the + // degenerate case; the facet still says something, an empty row does not. + assertEquals("Tokuno", placeLabel(AtlasPlaceDto(facet = "Tokuno"))) + assertEquals("Tokuno", placeLabel(AtlasPlaceDto(facet = "Tokuno", label = " "))) + } + @Test fun facetSummaryLeadsWithWhereItMostlyIs() { val creature = AtlasCreatureDto( slug = "lizardman", -- 2.49.1