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>
This commit is contained in:
2026-07-30 02:34:41 -05:00
parent 4fe7a7e2a3
commit 833e51de69
26 changed files with 915 additions and 51 deletions

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.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

View File

@@ -83,8 +83,47 @@ data class CharProfileDto(
val titles: TitlesDto? = null,
val guild: GuildRefDto? = null,
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
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<String> = emptyList(),
val rewardResolved: List<String?> = emptyList(),
val fameKarma: 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.
*/
/**
* 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
* 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(

View File

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

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

View File

@@ -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 <T> ApiResult<T>.toUiState(): UiState<T> = 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 <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
* 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
}

View File

@@ -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<MenuEntry> = 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<MenuEntry> = 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<MenuEntry>, session: Session): List<MenuEntry> =
fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> =
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))
}

View File

@@ -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<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)
@Composable
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
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<String> {
if (titles == null) return emptyList()
val out = mutableListOf<String>()
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<CharPointsDto> =
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() }

View File

@@ -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<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. */
fun 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.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()
}
}
}

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

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

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

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.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> =
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<FeedLine>,
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) ->

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.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<ShardHub>>(UiState.Loading)
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())
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
seedFeed()
}
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
is ApiResult.HttpError -> _state.value = status.toUiState()
is ApiResult.NetworkError -> _state.value = status.toUiState()
is ApiResult.HttpError -> _state.value = status.toShardUiState()
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
}
}
}