3 Commits

Author SHA1 Message Date
833e51de69 feat(shard): follow the visibility framework and read the Protocol 3.0 profile
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
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 <noreply@anthropic.com>
2026-07-30 02:34:41 -05:00
4fe7a7e2a3 Merge pull request 'feat(auth): persist the trust token returned by the SSO exchange' (#29) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
SonarQube / analysis (push) Successful in 5m41s
Release APK / release (push) Successful in 9m29s
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:11:30 +00:00
b10dd444b3 feat(auth): persist the trust token returned by the SSO exchange
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m55s
Pairs with website feat/sso-trusted-device, which makes "trust this device" work
for SSO sign-ins. Two things reach this device when the user ticks the box:

  1. The rg_trust COOKIE in the Custom Tab. Custom Tabs share the system
     browser's cookie jar, so that alone makes the next SSO sign-in skip the
     TOTP step — no app change needed for that half.
  2. A trustToken in the /auth/mobile/sso/exchange response, which is what this
     commit stores. That covers the app's NATIVE password login on the same
     device, which reads the token back out of TrustTokenStore and replays it as
     X-Trust-Token.

MobileTokenResponse already carried trustToken (the native login path has always
persisted it) — SsoAuthManager simply dropped it on the floor. Save it scoped to
the signed-in username, exactly like AuthRepository.login does, so it is never
replayed for a different account on a shared device; and save it before
onSignedIn so a process death mid-callback can't lose it.

Tests: 2 new cases in SsoAuthManagerTest (token persisted + scoped to its owner;
absent token leaves the store untouched), with an in-memory FakeTrustTokenStore
matching the file's existing fake style. Full unit suite green: 266 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:33 -05:00
28 changed files with 971 additions and 52 deletions

View File

@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore, private val pendingStore: PendingSsoStore,
private val trustTokenStore: TrustTokenStore,
) { ) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */ /** Why an SSO attempt ended, for a friendly inline message on the login screen. */
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
_outcome.value = Outcome.Failed(Failure.SERVER) _outcome.value = Outcome.Failed(Failure.SERVER)
return return
} }
// The user ticked "trust this device" on the TOTP form inside the Custom
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
// the token the exchange handed back is what lets a native PASSWORD login
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
// username exactly like the password path, so it is never replayed for a
// different account on a shared device. Saved BEFORE onSignedIn so a
// process death mid-callback can't lose it.
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user) sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success _outcome.value = Outcome.Success
return return

View File

@@ -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.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto 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.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -93,6 +94,14 @@ interface PublicApi {
suspend fun postContact(@Body body: ContactRequest): ContactResponse suspend fun postContact(@Body body: ContactRequest): ContactResponse
// ── Public shard widgets (§6.2) ────────────────────────────────────── // ── 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") @GET("api/v1/public/shard/status")
suspend fun getShardStatus(): ShardStatusDto suspend fun getShardStatus(): ShardStatusDto

View File

@@ -83,8 +83,47 @@ data class CharProfileDto(
val titles: TitlesDto? = null, val titles: TitlesDto? = null,
val guild: GuildRefDto? = null, val guild: GuildRefDto? = null,
val governorOf: List<String> = emptyList(), val governorOf: List<String> = 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<CharPointsDto> = 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 @Serializable
data class CharStatsDto( data class CharStatsDto(
val str: Int? = null, val str: Int? = null,
@@ -134,17 +173,45 @@ data class EquipmentDto(
val itemId: Int? = null, val itemId: Int? = null,
val hue: Int? = null, val hue: Int? = null,
val mods: JsonObject? = 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 * 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 * shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
* literal — numeric ones are skipped without a cliloc table (as the website does). *
* [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 @Serializable
data class TitlesDto( data class TitlesDto(
val selected: Int? = null, val selected: Int? = null,
val reward: List<String> = emptyList(), val reward: List<String> = emptyList(),
val rewardResolved: List<String?> = emptyList(),
val fameKarma: String? = null, val fameKarma: String? = null,
val skill: String? = null, val skill: String? = null,
) )

View File

@@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs. * `*.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<String> = emptyList(),
)
/** /**
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire * 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 * 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 * (e.g. `"0x1A2B"`), never numbers.
* string (e.g. `"9931"`) — both are decoded as strings, not parsed. *
* [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 @Serializable
data class ActorDto( data class ActorDto(

View File

@@ -28,6 +28,7 @@ class ConnectionRepository @Inject constructor(
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore, private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager, private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig, 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 // 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). // against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear() 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() prefs.clear()
baseUrlHolder.set(null) baseUrlHolder.set(null)
} }

View File

@@ -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<ShardFeatures?>(null)
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
val features: StateFlow<ShardFeatures?> = _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<String>,
)
/**
* 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"
}

View File

@@ -112,6 +112,8 @@ fun RunicApp(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val session by sessionViewModel.session.collectAsStateWithLifecycle() 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). // Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
@@ -132,7 +134,7 @@ fun RunicApp(
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
val entries = visibleEntries(APP_MENU, session) val entries = visibleEntries(APP_MENU, session, shardFeatures)
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,

View File

@@ -34,6 +34,13 @@ enum class ErrorKind {
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */ /** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
SHARD_OFFLINE, 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. */ /** Any other non-2xx server response. */
SERVER, SERVER,
} }
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
httpStatus = status, 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 <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()
}
else -> toUiState()
}

View File

@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
/** /**
* Whole-screen error state with a friendly, kind-specific message and a Retry * 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. * 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 @Composable
fun ErrorView( fun ErrorView(
@@ -53,15 +57,20 @@ fun ErrorView(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Button( if (isRetryable(kind)) {
onClick = onRetry, Button(
modifier = Modifier.padding(top = 16.dp).width(160.dp), onClick = onRetry,
) { modifier = Modifier.padding(top = 16.dp).width(160.dp),
Text(stringResource(R.string.action_retry)) ) {
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). */ /** Centered informational message for an empty list (§7). */
@Composable @Composable
fun EmptyView(message: String, modifier: Modifier = Modifier) { 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.NOT_FOUND -> R.string.error_not_found
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.SERVER -> R.string.error_server ErrorKind.SERVER -> R.string.error_server
} }

View File

@@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.runicgateway.app.R import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session 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 * One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -40,6 +43,15 @@ data class MenuEntry(
val route: String, val route: String,
@param:StringRes val labelRes: Int, @param:StringRes val labelRes: Int,
val access: MenuAccess = MenuAccess.PUBLIC, 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<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home), MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news), MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki), 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.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
@@ -67,16 +79,28 @@ val APP_MENU: List<MenuEntry> = listOf(
) )
/** /**
* The entries the given [session] may see. Pure + side-effect-free so the access * The entries the given [session] may see, given the shard [features] it may reach.
* gating is unit-tested without Compose. * 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<MenuEntry>, session: Session): List<MenuEntry> = fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> =
entries.filter { entry -> entries.filter { entry ->
when (entry.access) { val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff) MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
} }
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
} }

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R 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.CharProfileDto
import com.runicgateway.app.data.api.dto.CharStatsDto import com.runicgateway.app.data.api.dto.CharStatsDto
import com.runicgateway.app.data.api.dto.EquipmentDto 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?.let { AttributesBlock(it) }
char.stats?.resist?.let { ResistancesBlock(it) } char.stats?.resist?.let { ResistancesBlock(it) }
SkillsBlock(char.skills) SkillsBlock(char.skills)
PointsBlock(displayPoints(char))
EquipmentBlock(char.equipment) EquipmentBlock(char.equipment)
} }
} }
@@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List<SkillDto>) {
} }
} }
/**
* 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<CharPointsDto>) {
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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun EquipmentBlock(equipment: List<EquipmentDto>) { private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
equipment.forEach { item -> equipment.forEach { item ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text( Text(
item.layer ?: stringResource(R.string.player_char_item), item.label ?: stringResource(R.string.player_char_item),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
val meta = listOfNotNull( 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 * The human-readable title chips for a [TitlesDto] (parity with the website's
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected * `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 * reward title.
* (the app ships no cliloc table). De-duplicated, blanks dropped. *
* 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<String> { internal fun displayTitles(titles: TitlesDto?): List<String> {
if (titles == null) return emptyList() if (titles == null) return emptyList()
val out = mutableListOf<String>() val out = mutableListOf<String>()
titles.fameKarma?.let { out.add(it) } titles.fameKarma?.let { out.add(it) }
titles.skill?.let { out.add(it) } titles.skill?.let { out.add(it) }
val reward = titles.reward val reward = titles.reward.mapIndexed { i, raw ->
val sel = titles.selected ?: -1 titles.rewardResolved.getOrNull(i)
val candidate = when { ?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
sel in reward.indices -> reward[sel]
else -> reward.firstOrNull { it.isNotBlank() && !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() 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<CharPointsDto> =
char.points
.filter { (it.points ?: 0L) > 0L }
.sortedByDescending { it.points ?: 0L }
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String = private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() } runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }

View File

@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.repository.AuthRepository 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,10 +25,28 @@ import javax.inject.Inject
class SessionViewModel @Inject constructor( class SessionViewModel @Inject constructor(
sessionManager: SessionManager, sessionManager: SessionManager,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() { ) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state val session: StateFlow<Session> = 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<ShardFeatures?> = 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. */ /** Re-validate the cached role against the backend on app resume. */
fun revalidate() { fun revalidate() {
viewModelScope.launch { authRepository.revalidate() } viewModelScope.launch { authRepository.revalidate() }

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.ChampDto import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.repository.ShardRepository import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
board.seed(result.data) board.seed(result.data)
publish() publish()
} }
else -> _state.value = result.toUiState() else -> _state.value = result.toShardUiState()
} }
} }
} }

View File

@@ -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.api.dto.GovernorTermDto
import com.runicgateway.app.data.repository.ShardRepository import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
board.seed(result.data) board.seed(result.data)
publish() publish()
} }
else -> _state.value = result.toUiState() else -> _state.value = result.toShardUiState()
} }
} }
} }

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.GuildDto import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.repository.ShardRepository import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
board.seed(result.data) board.seed(result.data)
publish() publish()
} }
else -> _state.value = result.toUiState() else -> _state.value = result.toShardUiState()
} }
} }
} }

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.HouseDto import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.repository.ShardRepository import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
board.seed(result.data) board.seed(result.data)
publish() publish()
} }
else -> _state.value = result.toUiState() else -> _state.value = result.toShardUiState()
} }
} }
} }

View File

@@ -32,12 +32,33 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView 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.PillTone
import com.runicgateway.app.ui.components.SectionLabel import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.StatusPill 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> =
ShardBoard.entries.filter { canSee(features, it.feature) }
/** /**
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest * 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 state by viewModel.state.collectAsStateWithLifecycle()
val feed by viewModel.feed.collectAsStateWithLifecycle() val feed by viewModel.feed.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle() val connected by viewModel.connected.collectAsStateWithLifecycle()
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
when (val s = state) { when (val s = state) {
is UiState.Loading -> LoadingView(modifier) is UiState.Loading -> LoadingView(modifier)
@@ -62,6 +84,7 @@ fun ShardScreen(
hub = s.data, hub = s.data,
feed = feed, feed = feed,
connected = connected, connected = connected,
features = features,
onOpenBoard = onOpenBoard, onOpenBoard = onOpenBoard,
modifier = modifier, modifier = modifier,
) )
@@ -73,6 +96,7 @@ private fun HubContent(
hub: ShardHub, hub: ShardHub,
feed: List<FeedLine>, feed: List<FeedLine>,
connected: Boolean, connected: Boolean,
features: ShardFeatures?,
onOpenBoard: (ShardBoard) -> Unit, onOpenBoard: (ShardBoard) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -82,7 +106,7 @@ private fun HubContent(
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) { ) {
item { StatusCard(hub.status, hub.presence?.count) } item { StatusCard(hub.status, hub.presence?.count) }
item { BoardsCard(onOpenBoard) } item { BoardsCard(features, onOpenBoard) }
if (hub.online.isNotEmpty()) { if (hub.online.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.shard_section_staff)) } item { SectionHeader(stringResource(R.string.shard_section_staff)) }
@@ -159,13 +183,16 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
} }
@Composable @Composable
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) { private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
val boards = listOf( val boards = visibleBoards(features).map { board ->
ShardBoard.CHAMPS to R.string.shard_nav_champs, board to when (board) {
ShardBoard.GUILDS to R.string.shard_nav_guilds, ShardBoard.CHAMPS -> R.string.shard_nav_champs
ShardBoard.GOVERNORS to R.string.shard_nav_governors, ShardBoard.GUILDS -> R.string.shard_nav_guilds
ShardBoard.HOUSES to R.string.shard_nav_houses, ShardBoard.GOVERNORS -> R.string.shard_nav_governors
) ShardBoard.HOUSES -> R.string.shard_nav_houses
}
}
if (boards.isEmpty()) return
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column { Column {
boards.forEachIndexed { index, (board, labelRes) -> boards.forEachIndexed { index, (board, labelRes) ->

View File

@@ -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.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PresenceDto import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.ShardStatusDto 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.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -39,11 +41,18 @@ data class ShardHub(
@HiltViewModel @HiltViewModel
class ShardViewModel @Inject constructor( class ShardViewModel @Inject constructor(
private val repository: ShardRepository, private val repository: ShardRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading) private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow() val state: StateFlow<UiState<ShardHub>> = _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<ShardFeatures?> = shardFeaturesRepository.features
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList()) private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow() val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
seedFeed() seedFeed()
} }
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed. // Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
is ApiResult.HttpError -> _state.value = status.toUiState() is ApiResult.HttpError -> _state.value = status.toShardUiState()
is ApiResult.NetworkError -> _state.value = status.toUiState() is ApiResult.NetworkError -> _state.value = status.toShardUiState()
} }
} }
} }

View File

@@ -16,6 +16,7 @@
<string name="error_not_found">This content couldn\'t be found.</string> <string name="error_not_found">This content couldn\'t be found.</string>
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string> <string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
<string name="error_shard_offline">The shard is offline right now.</string> <string name="error_shard_offline">The shard is offline right now.</string>
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string> <string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── --> <!-- ── First-run connect (§3) ──────────────────────────────────────── -->
@@ -290,6 +291,11 @@
<string name="player_char_pois">Poison</string> <string name="player_char_pois">Poison</string>
<string name="player_char_energy">Energy</string> <string name="player_char_energy">Energy</string>
<string name="player_char_skills">Skills</string> <string name="player_char_skills">Skills</string>
<string name="player_char_points">Loyalty &amp; Points</string>
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
<string name="player_char_points_ranked">%1$s · #%2$d</string>
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
<string name="player_char_points_of">%1$d / %2$d</string>
<string name="player_char_equipment">Equipment</string> <string name="player_char_equipment">Equipment</string>
<string name="player_char_item">Item</string> <string name="player_char_item">Item</string>
<string name="player_char_item_id">id %1$d</string> <string name="player_char_item_id">id %1$d</string>

View File

@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.StoredSession import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
override fun clear() { pending = null } override fun clear() { pending = null }
} }
/** In-memory stand-in for the encrypted trust-token store, scoped by username
* the same way the production impl is. */
private class FakeTrustTokenStore : TrustTokenStore {
var owner: String? = null
var token: String? = null
override fun tokenFor(username: String): String? =
if (owner.equals(username, ignoreCase = true)) token else null
override fun save(username: String, token: String) { owner = username; this.token = token }
override fun clear() { owner = null; token = null }
}
/** Records the exchange it was called with and returns a scripted response. */ /** Records the exchange it was called with and returns a scripted response. */
private class FakeSsoApi( private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>, private val exchangeResult: () -> Response<MobileTokenResponse>,
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
session: SessionManager, session: SessionManager,
base: String? = "https://shard.example.com/", base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(), store: PendingSsoStore = FakePendingSsoStore(),
trust: TrustTokenStore = FakeTrustTokenStore(),
): SsoAuthManager { ): SsoAuthManager {
val holder = BaseUrlHolder() val holder = BaseUrlHolder()
if (base != null) holder.set(base.toHttpUrl()) if (base != null) holder.set(base.toHttpUrl())
return SsoAuthManager(api, session, holder, store) return SsoAuthManager(api, session, holder, store, trust)
} }
/** Build a start URL and pull the generated `state` back out of it. */ /** Build a start URL and pull the generated `state` back out of it. */
@@ -120,6 +133,38 @@ class SsoAuthManagerTest {
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value) assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
} }
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
// exchange additionally hands the APP its own token so a native password login
// on this device skips the code too. Before this, SSO ignored trust entirely.
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
val session = SessionManager(FakeTokenStore())
val trust = FakeTrustTokenStore()
val mgr = managerWith(api, session, trust = trust)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
assertEquals("opaque-trust", trust.tokenFor("alice"))
// Scoped to the account that minted it — never replayed for someone else.
assertNull(trust.tokenFor("mallory"))
}
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())
val trust = FakeTrustTokenStore()
val mgr = managerWith(api, session, trust = trust)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
assertNull(trust.tokenFor("alice"))
}
@Test fun `state mismatch fails without exchanging`() = runTest { @Test fun `state mismatch fails without exchanging`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) } val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore()) val session = SessionManager(FakeTokenStore())

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -99,4 +100,57 @@ class PlayerShardDtoTest {
assertTrue(dto.linked) assertTrue(dto.linked)
assertEquals("whitlocktech", dto.account) 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<CharProfileDto>(
"""{"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<CharProfileDto>("""{"serial":"0x24C","name":"Darrow"}""")
assertEquals(emptyList<CharPointsDto>(), dto.points)
}
@Test fun equipmentDecodesTheServerResolvedClilocName() {
val dto = json.decodeFromString<CharProfileDto>(
"""{"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<TitlesDto>(
"""{"selected":1,"reward":["1049565","1049566"],
"rewardResolved":[null,"Knight of Trinsic"]}""",
)
assertEquals(listOf("1049565", "1049566"), dto.reward)
assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved)
}
} }

View File

@@ -111,4 +111,30 @@ class ShardDtoTest {
assertEquals("bob", ActorDto(acct = "bob").label) assertEquals("bob", ActorDto(acct = "bob").label)
assertEquals("Someone", ActorDto().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<ActorDto>("""{"serial":"0x24C","name":"Darrow","player":true}""")
assertEquals("Darrow", dto.label)
assertNull(dto.acct)
assertNull(dto.webId)
}
@Test fun shardFeaturesDecodesTheRungAndVisibleSet() {
val dto = json.decodeFromString<ShardFeaturesDto>(
"""{"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<ShardFeaturesDto>("""{"level":"anonymous","features":[]}""")
assertEquals(emptyList<String>(), dto.features)
}
} }

View File

@@ -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.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto 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.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -56,6 +57,7 @@ class FakePublicApi : PublicApi {
var governors: List<GovernorDto> = emptyList() var governors: List<GovernorDto> = emptyList()
var governorHistory: List<GovernorTermDto> = emptyList() var governorHistory: List<GovernorTermDto> = emptyList()
var houses: List<HouseDto> = emptyList() var houses: List<HouseDto> = emptyList()
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */ /** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
var lastContact: ContactRequest? = null var lastContact: ContactRequest? = null
@@ -84,6 +86,7 @@ class FakePublicApi : PublicApi {
return reply(contactResponse) return reply(contactResponse)
} }
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus) override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed) override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy) override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)

View File

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

View File

@@ -4,7 +4,9 @@
package com.runicgateway.app.ui package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.ui.components.isRetryable
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
@@ -34,6 +36,44 @@ class UiStateTest {
assertTrue(ApiResult.HttpError(503).let { it.status == 503 }) 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 = private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind (ApiResult.HttpError(status).toUiState() as UiState.Error).kind
private fun shardKindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toShardUiState() as UiState.Error).kind
} }

View File

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

View File

@@ -3,14 +3,18 @@
*/ */
package com.runicgateway.app.ui.player 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 com.runicgateway.app.data.api.dto.TitlesDto
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
/** /**
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the * Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal* * website's `CharacterSheet.jsx`: title selection over the server's cliloc-resolved
* selected reward title, dropping bare cliloc numbers the app can't resolve. * parallel array, item naming precedence, and the Protocol 3.0 points block.
*/ */
class CharacterSheetHelpersTest { class CharacterSheetHelpersTest {
@@ -51,4 +55,100 @@ class CharacterSheetHelpersTest {
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great") val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
assertEquals(listOf("The Great"), displayTitles(titles)) 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<String>(), 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<CharPointsDto>(), displayPoints(CharProfileDto()))
}
} }

View File

@@ -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.dto.ShardStatusDto
import com.runicgateway.app.data.api.fake.FakePublicApi import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.api.fake.FakeShardStream 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.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule import com.runicgateway.app.util.MainDispatcherRule
@@ -39,6 +40,10 @@ class ShardBoardViewModelTest {
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json) 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 ───────────────────────────── // ── Champs: snapshot + live upsert/remove ─────────────────────────────
@Test fun champsSeedsSnapshotAndMergesLiveFrames() { @Test fun champsSeedsSnapshotAndMergesLiveFrames() {
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor")) api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
@@ -108,7 +113,7 @@ class ShardBoardViewModelTest {
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }), 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 val hub = (vm.state.value as UiState.Success).data
assertTrue(hub.status.isOnline) assertTrue(hub.status.isOnline)
// presence.online frame patched the count in place. // presence.online frame patched the count in place.
@@ -118,6 +123,6 @@ class ShardBoardViewModelTest {
@Test fun shardHubStatusErrorIsUiError() { @Test fun shardHubStatusErrorIsUiError() {
api.error = httpError(503) api.error = httpError(503)
assertTrue(ShardViewModel(repo()).state.value is UiState.Error) assertTrue(ShardViewModel(repo(), features()).state.value is UiState.Error)
} }
} }