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