Merge pull request 'feat(shard): follow the visibility framework and read the Protocol 3.0 profile' (#30) from feat/protocol-3-visibility into main
Reviewed-on: #30 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.api
|
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.ChampDto
|
||||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
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.GovernorTermDto
|
||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
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.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PageDto
|
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.PostDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
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.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
import com.runicgateway.app.data.api.dto.StatusDto
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
@@ -93,6 +102,14 @@ interface PublicApi {
|
|||||||
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||||
|
|
||||||
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Which shard features this caller may reach, so the menu hides entries instead
|
||||||
|
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
|
||||||
|
* call and a signed-in one can differ.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/shard/features")
|
||||||
|
suspend fun getShardFeatures(): ShardFeaturesDto
|
||||||
|
|
||||||
@GET("api/v1/public/shard/status")
|
@GET("api/v1/public/shard/status")
|
||||||
suspend fun getShardStatus(): ShardStatusDto
|
suspend fun getShardStatus(): ShardStatusDto
|
||||||
|
|
||||||
@@ -128,4 +145,65 @@ interface PublicApi {
|
|||||||
|
|
||||||
@GET("api/v1/public/shard/houses")
|
@GET("api/v1/public/shard/houses")
|
||||||
suspend fun getShardHouses(): List<HouseDto>
|
suspend fun getShardHouses(): List<HouseDto>
|
||||||
|
|
||||||
|
// ── 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<PointsBoardDto>
|
||||||
|
|
||||||
|
@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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,47 @@ data class CharProfileDto(
|
|||||||
val titles: TitlesDto? = null,
|
val titles: TitlesDto? = null,
|
||||||
val guild: GuildRefDto? = null,
|
val guild: GuildRefDto? = null,
|
||||||
val governorOf: List<String> = emptyList(),
|
val governorOf: List<String> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
|
||||||
|
* earned nothing anywhere — the shard omits systems the character has no entry in
|
||||||
|
* — and empty on a shard whose plugin predates 3.0.
|
||||||
|
*
|
||||||
|
* Served **ungated**: a character's own standings are self-service data on
|
||||||
|
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
|
||||||
|
* feature being visible. Don't re-gate them app-side.
|
||||||
|
*/
|
||||||
|
val points: List<CharPointsDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One point system a character holds a score in (Protocol 3.0 §7.3).
|
||||||
|
*
|
||||||
|
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
|
||||||
|
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
|
||||||
|
*
|
||||||
|
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
|
||||||
|
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
|
||||||
|
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
|
||||||
|
* Nothing may divide by it, and a full-width progress bar for an uncapped score
|
||||||
|
* would imply a completion that doesn't exist.
|
||||||
|
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
|
||||||
|
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
|
||||||
|
* Loyalty") is the PRIMARY display path, not a defensive fallback.
|
||||||
|
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
|
||||||
|
* Absent and "unranked" are different answers, so it renders only when sent.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CharPointsDto(
|
||||||
|
val system: String? = null,
|
||||||
|
val nameString: String? = null,
|
||||||
|
val points: Long? = null,
|
||||||
|
val maxPoints: Long? = null,
|
||||||
|
val rank: Int? = null,
|
||||||
|
) {
|
||||||
|
/** The cap, or null when the system is uncapped (see [maxPoints]). */
|
||||||
|
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CharStatsDto(
|
data class CharStatsDto(
|
||||||
val str: Int? = null,
|
val str: Int? = null,
|
||||||
@@ -134,17 +173,45 @@ data class EquipmentDto(
|
|||||||
val itemId: Int? = null,
|
val itemId: Int? = null,
|
||||||
val hue: Int? = null,
|
val hue: Int? = null,
|
||||||
val mods: JsonObject? = null,
|
val mods: JsonObject? = null,
|
||||||
)
|
/**
|
||||||
|
* A player-given name — set for the minority of items someone has renamed, null
|
||||||
|
* for almost everything else. The shard sends the plain `Item.Name` field; it
|
||||||
|
* never builds a display name (that call is a packet builder, not a field read).
|
||||||
|
*/
|
||||||
|
val name: String? = null,
|
||||||
|
/**
|
||||||
|
* The item's type name, resolved from its cliloc id **by the website** against
|
||||||
|
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
|
||||||
|
* table configured, which is fully supported — the sheet then falls back to the
|
||||||
|
* layer, exactly as it did before the table existed.
|
||||||
|
*/
|
||||||
|
val clilocName: String? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* What to call this item.
|
||||||
|
*
|
||||||
|
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
|
||||||
|
* not be relabelled "hatchet" — and the server applies the same precedence, so
|
||||||
|
* this only re-states it for an item that arrived with both.
|
||||||
|
*/
|
||||||
|
val label: String? get() = name ?: clilocName ?: layer
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
|
||||||
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
|
||||||
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
*
|
||||||
|
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
|
||||||
|
* into words against its cliloc table — same length and order as [reward], with a null
|
||||||
|
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
|
||||||
|
* the shard has no cliloc table, so read it positionally and tolerate it being short.
|
||||||
|
* See `displayTitles` in the character sheet.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TitlesDto(
|
data class TitlesDto(
|
||||||
val selected: Int? = null,
|
val selected: Int? = null,
|
||||||
val reward: List<String> = emptyList(),
|
val reward: List<String> = emptyList(),
|
||||||
|
val rewardResolved: List<String?> = emptyList(),
|
||||||
val fameKarma: String? = null,
|
val fameKarma: String? = null,
|
||||||
val skill: String? = null,
|
val skill: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<String, Boolean> = 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<PointsEntryDto> = 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<MarketListingDto> = 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<MarketListingDto> = 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<String> = emptyList(),
|
||||||
|
val regions: List<String> = 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<String, Int> = 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<String>`
|
||||||
|
* made the detail route fail to decode entirely.
|
||||||
|
*/
|
||||||
|
val places: List<AtlasPlaceDto> = 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<AtlasSpawnerDto> = emptyList(),
|
||||||
|
val spawnersTruncated: Boolean = false,
|
||||||
|
/** Creatures sharing its spawners — the detail route only. */
|
||||||
|
val alsoHere: List<AtlasCreatureDto> = 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<AtlasCreatureDto> = 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<String, Int> = emptyMap(),
|
||||||
|
val facets: List<String> = emptyList(),
|
||||||
|
)
|
||||||
@@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
|
||||||
|
* the audience rung they resolved to.
|
||||||
|
*
|
||||||
|
* Every shard-derived feature is admin-configurable — it can be switched off or
|
||||||
|
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
|
||||||
|
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
|
||||||
|
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
|
||||||
|
* since `player` means *a linked game account* and staff always satisfy it.
|
||||||
|
*
|
||||||
|
* The response reports only what the caller can see, so the list itself never
|
||||||
|
* discloses a feature they're gated out of.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ShardFeaturesDto(
|
||||||
|
val level: String? = null,
|
||||||
|
val features: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
||||||
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
||||||
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
|
* (e.g. `"0x1A2B"`), never numbers.
|
||||||
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
|
*
|
||||||
|
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
|
||||||
|
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
|
||||||
|
* not in-game-visible the way a character name is, so they are stripped from every
|
||||||
|
* response below `admin` and no admin setting can loosen that. The fields stay
|
||||||
|
* declared because an admin session does receive them; nothing below one should
|
||||||
|
* expect a value.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ActorDto(
|
data class ActorDto(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class ConnectionRepository @Inject constructor(
|
|||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val trustTokenStore: TrustTokenStore,
|
private val trustTokenStore: TrustTokenStore,
|
||||||
|
private val shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||||
private val config: com.runicgateway.app.core.AppConfig,
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
) {
|
) {
|
||||||
@@ -111,6 +112,10 @@ class ConnectionRepository @Inject constructor(
|
|||||||
// The trust token is bound to the old host — drop it so we don't replay it
|
// The trust token is bound to the old host — drop it so we don't replay it
|
||||||
// against a different shard (it survives a plain logout, but not a host switch).
|
// against a different shard (it survives a plain logout, but not a host switch).
|
||||||
trustTokenStore.clear()
|
trustTokenStore.clear()
|
||||||
|
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
|
||||||
|
// a switch between two signed-out hosts changes no session, so nothing else
|
||||||
|
// invalidates the cache and the new shard would inherit the old one's menu.
|
||||||
|
shardFeaturesRepository.invalidate()
|
||||||
prefs.clear()
|
prefs.clear()
|
||||||
baseUrlHolder.set(null)
|
baseUrlHolder.set(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which shard surfaces the current viewer may reach, from
|
||||||
|
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
|
||||||
|
*
|
||||||
|
* Every shard-derived feature is admin-configurable — it can be switched off, or its
|
||||||
|
* audience raised above the caller's rung — so shard navigation can no longer be a
|
||||||
|
* static list gated on the session role alone. [level] is the server's own answer on
|
||||||
|
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
|
||||||
|
* re-derive it.
|
||||||
|
*
|
||||||
|
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
|
||||||
|
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
|
||||||
|
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ShardFeaturesRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
private val _features = MutableStateFlow<ShardFeatures?>(null)
|
||||||
|
|
||||||
|
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
|
||||||
|
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
|
||||||
|
|
||||||
|
// Serializes concurrent refreshes: the shell refreshes on every session change,
|
||||||
|
// and two overlapping loads would race to publish.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
|
||||||
|
* a role revalidation that actually changed the user), because the answer is
|
||||||
|
* per-viewer.
|
||||||
|
*
|
||||||
|
* A failed lookup clears the cache rather than keeping a stale one: falling back
|
||||||
|
* to "show everything" is the safe direction here, since the server still gates
|
||||||
|
* every call.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() = mutex.withLock {
|
||||||
|
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
|
||||||
|
is ApiResult.Ok -> ShardFeatures(
|
||||||
|
level = result.data.level,
|
||||||
|
visible = result.data.features.toSet(),
|
||||||
|
)
|
||||||
|
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
|
||||||
|
// route — that site has no visibility framework, so "unknown" is exactly
|
||||||
|
// the right answer and the menu behaves as it did before M11.
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the cached answer. Called on a Settings → Server switch: the features
|
||||||
|
* belong to the host that reported them, and a switch between two signed-out
|
||||||
|
* hosts changes no session, so nothing else would invalidate them.
|
||||||
|
*/
|
||||||
|
fun invalidate() {
|
||||||
|
_features.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved visibility answer for one viewer: the rung the server placed them on
|
||||||
|
* and the shard features they may reach.
|
||||||
|
*/
|
||||||
|
data class ShardFeatures(
|
||||||
|
val level: String?,
|
||||||
|
val visible: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [feature] may be shown — **or when the answer isn't known yet**.
|
||||||
|
*
|
||||||
|
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
|
||||||
|
* the server gates every call regardless, so the cost of guessing wrong is a link that
|
||||||
|
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
|
||||||
|
* flickers its entries in on every cold start.
|
||||||
|
*/
|
||||||
|
fun canSee(features: ShardFeatures?, feature: String): Boolean =
|
||||||
|
features == null || feature in features.visible
|
||||||
|
|
||||||
|
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
|
||||||
|
object ShardFeature {
|
||||||
|
const val STATUS = "status"
|
||||||
|
const val ACTIVITY = "activity"
|
||||||
|
const val CHAMPS = "champs"
|
||||||
|
const val GUILDS = "guilds"
|
||||||
|
const val GOVERNORS = "governors"
|
||||||
|
const val HOUSES = "houses"
|
||||||
|
const val PRESENCE = "presence"
|
||||||
|
|
||||||
|
// Added by Protocol 3.0.
|
||||||
|
const val RULESET = "ruleset"
|
||||||
|
const val ATLAS = "atlas"
|
||||||
|
const val LEADERBOARDS = "leaderboards"
|
||||||
|
const val MARKET = "market"
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import com.runicgateway.app.core.net.ShardStreamEvent
|
|||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
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.ChampDto
|
||||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
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.GovernorTermDto
|
||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
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.OnlineStaffDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
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 com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
@@ -62,6 +69,59 @@ class ShardRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
|
suspend fun houses(): ApiResult<List<HouseDto>> = 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<RulesetDto?> = safeApiCall { api.getShardRuleset() }
|
||||||
|
|
||||||
|
suspend fun pointsBoards(): ApiResult<List<PointsBoardDto>> = safeApiCall { api.getShardPoints() }
|
||||||
|
|
||||||
|
suspend fun pointsBoard(system: String): ApiResult<PointsBoardDto> =
|
||||||
|
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<MarketPageDto> = 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<MarketMetaDto> = safeApiCall { api.getShardMarketMeta() }
|
||||||
|
|
||||||
|
suspend fun marketVendor(serial: String): ApiResult<MarketVendorDto> =
|
||||||
|
safeApiCall { api.getShardMarketVendor(serial) }
|
||||||
|
|
||||||
|
suspend fun atlasCreatures(
|
||||||
|
query: String? = null,
|
||||||
|
facet: String? = null,
|
||||||
|
limit: Int = ATLAS_PAGE,
|
||||||
|
offset: Int = 0,
|
||||||
|
): ApiResult<AtlasCreaturePageDto> = safeApiCall {
|
||||||
|
api.getAtlasCreatures(
|
||||||
|
query = query?.takeIf { it.isNotBlank() },
|
||||||
|
facet = facet?.takeIf { it.isNotBlank() },
|
||||||
|
limit = limit,
|
||||||
|
offset = offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun atlasCreature(slug: String): ApiResult<AtlasCreatureDto> =
|
||||||
|
safeApiCall { api.getAtlasCreature(slug) }
|
||||||
|
|
||||||
// ── Live stream ──────────────────────────────────────────────────────
|
// ── Live stream ──────────────────────────────────────────────────────
|
||||||
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||||
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||||
@@ -73,9 +133,27 @@ class ShardRepository @Inject constructor(
|
|||||||
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
||||||
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.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 <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||||
json.decodeFromJsonElement(serializer, obj)
|
json.decodeFromJsonElement(serializer, obj)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,10 +72,16 @@ import com.runicgateway.app.ui.player.CharactersScreen
|
|||||||
import com.runicgateway.app.ui.player.MyHousesScreen
|
import com.runicgateway.app.ui.player.MyHousesScreen
|
||||||
import com.runicgateway.app.ui.player.VendorsScreen
|
import com.runicgateway.app.ui.player.VendorsScreen
|
||||||
import com.runicgateway.app.ui.session.SessionViewModel
|
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.ChampsScreen
|
||||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||||
import com.runicgateway.app.ui.shard.HousesScreen
|
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.ShardBoard
|
||||||
import com.runicgateway.app.ui.shard.ShardScreen
|
import com.runicgateway.app.ui.shard.ShardScreen
|
||||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
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. */
|
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||||
private val TOP_LEVEL_ROUTES = setOf(
|
private val TOP_LEVEL_ROUTES = setOf(
|
||||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
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.NOTIFICATIONS,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
@@ -112,6 +121,8 @@ fun RunicApp(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||||
|
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||||
|
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
@@ -132,7 +143,7 @@ fun RunicApp(
|
|||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
val entries = visibleEntries(APP_MENU, session)
|
val entries = visibleEntries(APP_MENU, session, shardFeatures)
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -303,6 +314,30 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
||||||
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
||||||
composable(Routes.SHARD_HOUSES) { HousesScreen() }
|
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) {
|
composable(Routes.WIKI) {
|
||||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ enum class ErrorKind {
|
|||||||
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
||||||
SHARD_OFFLINE,
|
SHARD_OFFLINE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This shard doesn't publish the surface, or doesn't publish it to this viewer
|
||||||
|
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
|
||||||
|
* may well be up, and retrying changes nothing — an admin decides this.
|
||||||
|
*/
|
||||||
|
FEATURE_UNAVAILABLE,
|
||||||
|
|
||||||
/** Any other non-2xx server response. */
|
/** Any other non-2xx server response. */
|
||||||
SERVER,
|
SERVER,
|
||||||
}
|
}
|
||||||
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
|||||||
httpStatus = status,
|
httpStatus = status,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
|
||||||
|
*
|
||||||
|
* The website's `requireFeature` gate answers `404` when a feature is switched off —
|
||||||
|
* deliberately, so the response doesn't disclose that the surface exists — and `403`
|
||||||
|
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
|
||||||
|
* On these routes a `404` therefore almost never means "no such thing"; it means this
|
||||||
|
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
|
||||||
|
* invite the user to retry something an admin controls.
|
||||||
|
*
|
||||||
|
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
|
||||||
|
* mean something else off the shard surface: `404` is a genuinely missing item (a
|
||||||
|
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
|
||||||
|
* player or admin route, which is not an admin's visibility setting.
|
||||||
|
*/
|
||||||
|
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
|
||||||
|
is ApiResult.HttpError -> if (status == 403 || status == 404) {
|
||||||
|
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
|
||||||
|
} else {
|
||||||
|
toUiState()
|
||||||
|
}
|
||||||
|
else -> toUiState()
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
|
|||||||
/**
|
/**
|
||||||
* Whole-screen error state with a friendly, kind-specific message and a Retry
|
* Whole-screen error state with a friendly, kind-specific message and a Retry
|
||||||
* button (§7). Copy is resolved from string resources so it stays localizable.
|
* button (§7). Copy is resolved from string resources so it stays localizable.
|
||||||
|
*
|
||||||
|
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
|
||||||
|
* whether the shard publishes that surface, so retrying cannot change the answer and
|
||||||
|
* offering it would read as a transient failure the user could wait out (M11).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorView(
|
fun ErrorView(
|
||||||
@@ -53,6 +57,7 @@ fun ErrorView(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
if (isRetryable(kind)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onRetry,
|
onClick = onRetry,
|
||||||
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
||||||
@@ -60,8 +65,12 @@ fun ErrorView(
|
|||||||
Text(stringResource(R.string.action_retry))
|
Text(stringResource(R.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
|
||||||
|
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
|
||||||
|
|
||||||
/** Centered informational message for an empty list (§7). */
|
/** Centered informational message for an empty list (§7). */
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
||||||
@@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
|||||||
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
||||||
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||||
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||||
|
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
||||||
ErrorKind.SERVER -> R.string.error_server
|
ErrorKind.SERVER -> R.string.error_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeature
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.canSee
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||||
@@ -40,6 +43,15 @@ data class MenuEntry(
|
|||||||
val route: String,
|
val route: String,
|
||||||
@param:StringRes val labelRes: Int,
|
@param:StringRes val labelRes: Int,
|
||||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||||
|
/**
|
||||||
|
* For a shard-derived surface, the visibility feature it belongs to (M11).
|
||||||
|
*
|
||||||
|
* Session role is not the only gate on these: an admin can switch a feature off
|
||||||
|
* or raise its audience above the caller's rung, so the entry is filtered by
|
||||||
|
* `GET /public/shard/features` as well as by [access]. `null` means the entry
|
||||||
|
* isn't shard-derived and only [access] applies.
|
||||||
|
*/
|
||||||
|
val feature: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +63,13 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
|
||||||
|
// 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.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||||
@@ -67,16 +85,28 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
* The entries the given [session] may see, given the shard [features] it may reach.
|
||||||
* gating is unit-tested without Compose.
|
* Pure + side-effect-free so the gating is unit-tested without Compose.
|
||||||
|
*
|
||||||
|
* Two independent filters, and both must pass:
|
||||||
|
*
|
||||||
|
* - [MenuEntry.access] against the session — who the caller is.
|
||||||
|
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
||||||
|
* publishes at all (M11). `null` [features] means the answer isn't known yet and
|
||||||
|
* every shard entry shows; see [canSee] for why that direction is deliberate.
|
||||||
*/
|
*/
|
||||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
fun visibleEntries(
|
||||||
|
entries: List<MenuEntry>,
|
||||||
|
session: Session,
|
||||||
|
features: ShardFeatures? = null,
|
||||||
|
): List<MenuEntry> =
|
||||||
entries.filter { entry ->
|
entries.filter { entry ->
|
||||||
when (entry.access) {
|
val allowedByRole = when (entry.access) {
|
||||||
MenuAccess.PUBLIC -> true
|
MenuAccess.PUBLIC -> true
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||||
}
|
}
|
||||||
|
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ object Routes {
|
|||||||
const val SHARD_GOVERNORS = "shard/governors"
|
const val SHARD_GOVERNORS = "shard/governors"
|
||||||
const val SHARD_HOUSES = "shard/houses"
|
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. */
|
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
|
||||||
const val PLAYER_CHARACTERS = "player/characters"
|
const val PLAYER_CHARACTERS = "player/characters"
|
||||||
const val PLAYER_VENDORS = "player/vendors"
|
const val PLAYER_VENDORS = "player/vendors"
|
||||||
@@ -72,6 +84,12 @@ object Routes {
|
|||||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||||
fun playerChar(serial: String) = "player/char/$serial"
|
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
|
* 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;
|
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.CharPointsDto
|
||||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
import com.runicgateway.app.data.api.dto.CharStatsDto
|
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||||
@@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
|
|||||||
char.stats?.let { AttributesBlock(it) }
|
char.stats?.let { AttributesBlock(it) }
|
||||||
char.stats?.resist?.let { ResistancesBlock(it) }
|
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||||
SkillsBlock(char.skills)
|
SkillsBlock(char.skills)
|
||||||
|
PointsBlock(displayPoints(char))
|
||||||
EquipmentBlock(char.equipment)
|
EquipmentBlock(char.equipment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List<SkillDto>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a
|
||||||
|
* character that has earned nothing anywhere, which is a normal state.
|
||||||
|
*
|
||||||
|
* Only a system with a real cap gets a meter: an uncapped score
|
||||||
|
* ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be
|
||||||
|
* a fraction of, and a full-width bar would imply a completion that doesn't exist.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PointsBlock(points: List<CharPointsDto>) {
|
||||||
|
if (points.isEmpty()) return
|
||||||
|
SheetCard(R.string.player_char_points) {
|
||||||
|
points.forEach { entry ->
|
||||||
|
val cap = entry.cap
|
||||||
|
val score = entry.points ?: 0L
|
||||||
|
Column(Modifier.padding(vertical = 5.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
// `rank` is absent unless the shard opts in; absent and
|
||||||
|
// "unranked" are different, so the suffix only appears when sent.
|
||||||
|
entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) }
|
||||||
|
?: pointsLabel(entry),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||||
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
|||||||
equipment.forEach { item ->
|
equipment.forEach { item ->
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
item.layer ?: stringResource(R.string.player_char_item),
|
item.label ?: stringResource(R.string.player_char_item),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
val meta = listOfNotNull(
|
val meta = listOfNotNull(
|
||||||
@@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String =
|
|||||||
/**
|
/**
|
||||||
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||||
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||||
* reward title — but only if it is a literal string, not a bare cliloc number
|
* reward title.
|
||||||
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
*
|
||||||
|
* Reward entries arrive as either a literal or a cliloc number in string form. The
|
||||||
|
* server now resolves the numeric ones into `rewardResolved`, a **parallel** array
|
||||||
|
* (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry
|
||||||
|
* that didn't resolve becomes null and is skipped, but must not shift the `selected`
|
||||||
|
* index onto its neighbour. A number with no resolution is still skipped rather than
|
||||||
|
* rendered as a raw id, which is also the whole behavior on a shard that configures
|
||||||
|
* no cliloc table.
|
||||||
|
*
|
||||||
|
* Falling back to the first title that resolved (rather than showing nothing) matters
|
||||||
|
* when the *selected* one is the unresolved entry. De-duplicated, blanks dropped.
|
||||||
*/
|
*/
|
||||||
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||||
if (titles == null) return emptyList()
|
if (titles == null) return emptyList()
|
||||||
val out = mutableListOf<String>()
|
val out = mutableListOf<String>()
|
||||||
titles.fameKarma?.let { out.add(it) }
|
titles.fameKarma?.let { out.add(it) }
|
||||||
titles.skill?.let { out.add(it) }
|
titles.skill?.let { out.add(it) }
|
||||||
val reward = titles.reward
|
val reward = titles.reward.mapIndexed { i, raw ->
|
||||||
val sel = titles.selected ?: -1
|
titles.rewardResolved.getOrNull(i)
|
||||||
val candidate = when {
|
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
|
||||||
sel in reward.indices -> reward[sel]
|
|
||||||
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
|
||||||
}
|
}
|
||||||
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
|
||||||
|
if (!candidate.isNullOrBlank()) out.add(candidate)
|
||||||
return out.filter { it.isNotBlank() }.distinct()
|
return out.filter { it.isNotBlank() }.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A point system's display name: the shard's own [CharPointsDto.nameString] when it
|
||||||
|
* has one, else the humanised `PointsType` key.
|
||||||
|
*
|
||||||
|
* The fallback is the PRIMARY path, not a defensive nicety — most systems name
|
||||||
|
* themselves with a cliloc, so `nameString` comes back null for four of five boards
|
||||||
|
* on a real shard (`docs/link/v3.md` §7.5). Parity with the website's
|
||||||
|
* `humanisePoints`.
|
||||||
|
*/
|
||||||
|
internal fun pointsLabel(entry: CharPointsDto): String {
|
||||||
|
entry.nameString?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
|
val key = entry.system.orEmpty()
|
||||||
|
return key
|
||||||
|
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
|
||||||
|
.replaceFirstChar { it.uppercaseChar() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The points block, best standing first, dropping systems the character has no score
|
||||||
|
* in. Guarded for an older shard plugin that sends no `points` block at all.
|
||||||
|
*/
|
||||||
|
internal fun displayPoints(char: CharProfileDto): List<CharPointsDto> =
|
||||||
|
char.points
|
||||||
|
.filter { (it.points ?: 0L) > 0L }
|
||||||
|
.sortedByDescending { it.points ?: 0L }
|
||||||
|
|
||||||
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||||
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.runicgateway.app.core.auth.Session
|
import com.runicgateway.app.core.auth.Session
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.data.repository.AuthRepository
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -23,10 +25,28 @@ import javax.inject.Inject
|
|||||||
class SessionViewModel @Inject constructor(
|
class SessionViewModel @Inject constructor(
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
|
shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val session: StateFlow<Session> = sessionManager.state
|
val session: StateFlow<Session> = sessionManager.state
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which shard features this viewer may reach (M11). Held here beside [session]
|
||||||
|
* because it answers the same question for the same consumer: what the shared
|
||||||
|
* menu reveals. Role and feature config are independent gates — see
|
||||||
|
* [com.runicgateway.app.ui.navigation.visibleEntries].
|
||||||
|
*/
|
||||||
|
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||||
|
|
||||||
|
init {
|
||||||
|
// The answer is per-viewer, so it is re-resolved on every session change.
|
||||||
|
// A StateFlow conflates equal values, so a resume revalidation that returns
|
||||||
|
// the same user does not refetch — only a real sign-in/out/role change does.
|
||||||
|
viewModelScope.launch {
|
||||||
|
session.collect { shardFeaturesRepository.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-validate the cached role against the backend on app resume. */
|
/** Re-validate the cached role against the backend on app resume. */
|
||||||
fun revalidate() {
|
fun revalidate() {
|
||||||
viewModelScope.launch { authRepository.revalidate() }
|
viewModelScope.launch { authRepository.revalidate() }
|
||||||
|
|||||||
301
app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt
Normal file
301
app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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<AtlasCreaturePageDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<AtlasCreaturePageDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
private val _facet = MutableStateFlow<String?>(null)
|
||||||
|
val facet: StateFlow<String?> = _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<List<String>>(emptyList())
|
||||||
|
val facets: StateFlow<List<String>> = _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<AtlasCreatureDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<AtlasCreatureDto>> = _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"
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.ChampDto
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
|||||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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<PointsBoardDto> { it.system.orEmpty() }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<PointsBoardDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<PointsBoardDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _connected = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
collectLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.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<PointsBoardDto>): List<PointsBoardDto> =
|
||||||
|
boards
|
||||||
|
.filter { it.showOnGump }
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<PointsBoardDto> { it.players ?: 0 }
|
||||||
|
.thenBy { (it.nameString ?: it.system).orEmpty().lowercase() },
|
||||||
|
)
|
||||||
260
app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt
Normal file
260
app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MarketPageDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<MarketPageDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _meta = MutableStateFlow<MarketMetaDto?>(null)
|
||||||
|
val meta: StateFlow<MarketMetaDto?> = _meta.asStateFlow()
|
||||||
|
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
private val _sort = MutableStateFlow(ShardRepository.SORT_PRICE_ASC)
|
||||||
|
val sort: StateFlow<String> = _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<MarketVendorDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<MarketVendorDto>> = _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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt
Normal file
207
app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt
Normal file
@@ -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<String, Boolean>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<RulesetDto?>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<RulesetDto?>> = _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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,33 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.FeatureCard
|
import com.runicgateway.app.ui.components.FeatureCard
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeature
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.canSee
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
import com.runicgateway.app.ui.components.SectionLabel
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/** Board destinations reachable from the hub. */
|
/**
|
||||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
* Board destinations reachable from the hub, each tagged with the visibility feature
|
||||||
|
* that governs it (M11). An admin can switch any of these off or raise its audience,
|
||||||
|
* so the hub's board list is filtered the same way the drawer is — a tile whose
|
||||||
|
* feature the caller can't see would only lead to a `404`/`403`.
|
||||||
|
*/
|
||||||
|
enum class ShardBoard(val feature: String) {
|
||||||
|
CHAMPS(ShardFeature.CHAMPS),
|
||||||
|
GUILDS(ShardFeature.GUILDS),
|
||||||
|
GOVERNORS(ShardFeature.GOVERNORS),
|
||||||
|
HOUSES(ShardFeature.HOUSES),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boards this viewer may reach. Pure + side-effect-free so the gating is
|
||||||
|
* unit-tested without Compose, exactly like `visibleEntries` for the drawer. An
|
||||||
|
* unknown answer shows every board — the server gates regardless (see [canSee]).
|
||||||
|
*/
|
||||||
|
fun visibleBoards(features: ShardFeatures?): List<ShardBoard> =
|
||||||
|
ShardBoard.entries.filter { canSee(features, it.feature) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
||||||
@@ -54,6 +75,7 @@ fun ShardScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
||||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||||
|
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is UiState.Loading -> LoadingView(modifier)
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
@@ -62,6 +84,7 @@ fun ShardScreen(
|
|||||||
hub = s.data,
|
hub = s.data,
|
||||||
feed = feed,
|
feed = feed,
|
||||||
connected = connected,
|
connected = connected,
|
||||||
|
features = features,
|
||||||
onOpenBoard = onOpenBoard,
|
onOpenBoard = onOpenBoard,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -73,6 +96,7 @@ private fun HubContent(
|
|||||||
hub: ShardHub,
|
hub: ShardHub,
|
||||||
feed: List<FeedLine>,
|
feed: List<FeedLine>,
|
||||||
connected: Boolean,
|
connected: Boolean,
|
||||||
|
features: ShardFeatures?,
|
||||||
onOpenBoard: (ShardBoard) -> Unit,
|
onOpenBoard: (ShardBoard) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -82,7 +106,7 @@ private fun HubContent(
|
|||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||||
) {
|
) {
|
||||||
item { StatusCard(hub.status, hub.presence?.count) }
|
item { StatusCard(hub.status, hub.presence?.count) }
|
||||||
item { BoardsCard(onOpenBoard) }
|
item { BoardsCard(features, onOpenBoard) }
|
||||||
|
|
||||||
if (hub.online.isNotEmpty()) {
|
if (hub.online.isNotEmpty()) {
|
||||||
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
||||||
@@ -159,13 +183,16 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
|
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
|
||||||
val boards = listOf(
|
val boards = visibleBoards(features).map { board ->
|
||||||
ShardBoard.CHAMPS to R.string.shard_nav_champs,
|
board to when (board) {
|
||||||
ShardBoard.GUILDS to R.string.shard_nav_guilds,
|
ShardBoard.CHAMPS -> R.string.shard_nav_champs
|
||||||
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
|
ShardBoard.GUILDS -> R.string.shard_nav_guilds
|
||||||
ShardBoard.HOUSES to R.string.shard_nav_houses,
|
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
|
||||||
)
|
ShardBoard.HOUSES -> R.string.shard_nav_houses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (boards.isEmpty()) return
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column {
|
||||||
boards.forEachIndexed { index, (board, labelRes) ->
|
boards.forEachIndexed { index, (board, labelRes) ->
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -39,11 +41,18 @@ data class ShardHub(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ShardViewModel @Inject constructor(
|
class ShardViewModel @Inject constructor(
|
||||||
private val repository: ShardRepository,
|
private val repository: ShardRepository,
|
||||||
|
shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
||||||
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which boards to offer (M11). Read-only here — the app shell refreshes this on
|
||||||
|
* every session change, and the hub only filters its tiles with it.
|
||||||
|
*/
|
||||||
|
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||||
|
|
||||||
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
||||||
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
||||||
|
|
||||||
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
|
|||||||
seedFeed()
|
seedFeed()
|
||||||
}
|
}
|
||||||
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
||||||
is ApiResult.HttpError -> _state.value = status.toUiState()
|
is ApiResult.HttpError -> _state.value = status.toShardUiState()
|
||||||
is ApiResult.NetworkError -> _state.value = status.toUiState()
|
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<string name="error_not_found">This content couldn\'t be found.</string>
|
<string name="error_not_found">This content couldn\'t be found.</string>
|
||||||
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
|
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
|
||||||
<string name="error_shard_offline">The shard is offline right now.</string>
|
<string name="error_shard_offline">The shard is offline right now.</string>
|
||||||
|
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
|
||||||
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
||||||
|
|
||||||
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||||
@@ -40,6 +41,10 @@
|
|||||||
<string name="menu_news">News</string>
|
<string name="menu_news">News</string>
|
||||||
<string name="menu_wiki">Wiki</string>
|
<string name="menu_wiki">Wiki</string>
|
||||||
<string name="menu_shard">Shard</string>
|
<string name="menu_shard">Shard</string>
|
||||||
|
<string name="menu_rules">Rules</string>
|
||||||
|
<string name="menu_atlas">Atlas</string>
|
||||||
|
<string name="menu_leaderboards">Leaderboards</string>
|
||||||
|
<string name="menu_market">Market</string>
|
||||||
<string name="menu_about">About</string>
|
<string name="menu_about">About</string>
|
||||||
<string name="menu_contact">Contact</string>
|
<string name="menu_contact">Contact</string>
|
||||||
<string name="menu_account">My account</string>
|
<string name="menu_account">My account</string>
|
||||||
@@ -290,6 +295,11 @@
|
|||||||
<string name="player_char_pois">Poison</string>
|
<string name="player_char_pois">Poison</string>
|
||||||
<string name="player_char_energy">Energy</string>
|
<string name="player_char_energy">Energy</string>
|
||||||
<string name="player_char_skills">Skills</string>
|
<string name="player_char_skills">Skills</string>
|
||||||
|
<string name="player_char_points">Loyalty & Points</string>
|
||||||
|
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
|
||||||
|
<string name="player_char_points_ranked">%1$s · #%2$d</string>
|
||||||
|
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
|
||||||
|
<string name="player_char_points_of">%1$d / %2$d</string>
|
||||||
<string name="player_char_equipment">Equipment</string>
|
<string name="player_char_equipment">Equipment</string>
|
||||||
<string name="player_char_item">Item</string>
|
<string name="player_char_item">Item</string>
|
||||||
<string name="player_char_item_id">id %1$d</string>
|
<string name="player_char_item_id">id %1$d</string>
|
||||||
@@ -376,6 +386,85 @@
|
|||||||
<string name="guilds_leader">Led by %1$s</string>
|
<string name="guilds_leader">Led by %1$s</string>
|
||||||
<string name="guilds_alliance">Alliance: %1$s</string>
|
<string name="guilds_alliance">Alliance: %1$s</string>
|
||||||
|
|
||||||
|
<!-- ── Rules / ruleset (Protocol 3.0 §5, M11) ──────────────────────── -->
|
||||||
|
<!-- A successful read of a shard that has never published its ruleset — NOT the same
|
||||||
|
as the feature being switched off, which renders as an error state. -->
|
||||||
|
<string name="rules_unpublished">This shard hasn\'t published its ruleset yet.</string>
|
||||||
|
<string name="rules_section_shard">Shard</string>
|
||||||
|
<string name="rules_section_systems">Systems</string>
|
||||||
|
<string name="rules_section_caps">Skill & stat caps</string>
|
||||||
|
<string name="rules_section_accounts">Accounts & housing</string>
|
||||||
|
<string name="rules_section_vendors">Vendors</string>
|
||||||
|
<string name="rules_section_schedule">Saves & restarts</string>
|
||||||
|
<string name="rules_name">Name</string>
|
||||||
|
<string name="rules_expansion">Expansion</string>
|
||||||
|
<string name="rules_connect">Connect</string>
|
||||||
|
<string name="rules_skill_cap">Individual skill cap</string>
|
||||||
|
<string name="rules_total_skill_cap">Total skill cap</string>
|
||||||
|
<string name="rules_stat_cap">Total stat cap</string>
|
||||||
|
<string name="rules_str_cap">Strength cap</string>
|
||||||
|
<string name="rules_dex_cap">Dexterity cap</string>
|
||||||
|
<string name="rules_int_cap">Intelligence cap</string>
|
||||||
|
<string name="rules_char_slots">Character slots</string>
|
||||||
|
<string name="rules_per_ip">Accounts per IP</string>
|
||||||
|
<string name="rules_house_limit">Houses per account</string>
|
||||||
|
<string name="rules_restock_delay">Vendor restock delay</string>
|
||||||
|
<string name="rules_max_sell">Max sell quantity</string>
|
||||||
|
<string name="rules_autosave">Auto-save every</string>
|
||||||
|
<string name="rules_autorestart">Auto-restart at</string>
|
||||||
|
<string name="rules_minutes">%1$d min</string>
|
||||||
|
|
||||||
|
<!-- ── Leaderboards (Protocol 3.0 §7, M11) ─────────────────────────── -->
|
||||||
|
<string name="leaderboards_empty">This shard isn\'t publishing any leaderboards yet.</string>
|
||||||
|
<string name="leaderboards_board_empty">Nobody has scored here yet.</string>
|
||||||
|
<!-- Where a score would sit on the placeholder row of an unscored board. An em
|
||||||
|
dash, not "0" — nobody has scored zero, nobody has scored at all. -->
|
||||||
|
<string name="leaderboards_no_score">—</string>
|
||||||
|
<string name="leaderboards_players">%1$d players</string>
|
||||||
|
<!-- Only shown for capped systems; most systems on a real shard are uncapped. -->
|
||||||
|
<string name="leaderboards_cap">Cap: %1$d</string>
|
||||||
|
<string name="leaderboards_rank_name">#%1$d %2$s</string>
|
||||||
|
<!-- A shard may publish standings without naming who holds them (the board's one
|
||||||
|
admin-configurable field). -->
|
||||||
|
<string name="leaderboards_hidden_name">Someone</string>
|
||||||
|
|
||||||
|
<!-- ── Market (Protocol 3.0 §8, M11) ───────────────────────────────── -->
|
||||||
|
<string name="market_search_label">Search every shop</string>
|
||||||
|
<string name="market_empty">No listings match that search.</string>
|
||||||
|
<string name="market_shop_empty">This shop has nothing for sale.</string>
|
||||||
|
<!-- Required, not decoration: the shard sweeps vendors round-robin, so a price can
|
||||||
|
legitimately be a full cycle old. -->
|
||||||
|
<string name="market_staleness">Prices are refreshed in rotation and may be out of date.</string>
|
||||||
|
<string name="market_price">%1$d gp</string>
|
||||||
|
<string name="market_unnamed_item">Item %1$d</string>
|
||||||
|
<string name="market_unnamed_shop">A shop</string>
|
||||||
|
<string name="market_owner">Kept by %1$s</string>
|
||||||
|
<!-- A gated location is a real answer: the shop exists, the shard just doesn't say
|
||||||
|
where it stands. -->
|
||||||
|
<string name="market_location_hidden">This shard doesn\'t publish shop locations.</string>
|
||||||
|
<string name="market_truncated">Showing %1$d of %2$d — this shop holds more than the shard publishes.</string>
|
||||||
|
|
||||||
|
<!-- ── Spawn atlas (Protocol 3.0 §6, M11) ──────────────────────────── -->
|
||||||
|
<string name="atlas_search_label">Search creatures</string>
|
||||||
|
<string name="atlas_empty">No creatures match that search.</string>
|
||||||
|
<!-- A place can legitimately hold a single spawner, and the aggregate list is full
|
||||||
|
of them — "1 spawners" on every other row is worth a plural for. -->
|
||||||
|
<plurals name="atlas_spawner_count">
|
||||||
|
<item quantity="one">%1$d spawner</item>
|
||||||
|
<item quantity="other">%1$d spawners</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="atlas_total_alive">Up to %1$d alive at once</string>
|
||||||
|
<string name="atlas_facet_count">%1$s (%2$d)</string>
|
||||||
|
<!-- The aggregate: "where is it", as opposed to the raw coordinates below it. -->
|
||||||
|
<string name="atlas_section_places">Where it spawns</string>
|
||||||
|
<string name="atlas_place_max_alive">up to %1$d at once</string>
|
||||||
|
<string name="atlas_section_spawners">Spawn points</string>
|
||||||
|
<string name="atlas_section_also_here">Also spawns here</string>
|
||||||
|
<string name="atlas_spawners_truncated">More spawn points than shown.</string>
|
||||||
|
<string name="atlas_max_count">Up to %1$d</string>
|
||||||
|
<!-- The delay is already in seconds; the server normalizes XmlSpawner's mixed units. -->
|
||||||
|
<string name="atlas_respawn">Respawn %1$s</string>
|
||||||
|
|
||||||
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
|
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
|
||||||
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
|
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
|
||||||
<string name="governor_current">Governed by %1$s</string>
|
<string name="governor_current">Governed by %1$s</string>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -99,4 +100,57 @@ class PlayerShardDtoTest {
|
|||||||
assertTrue(dto.linked)
|
assertTrue(dto.linked)
|
||||||
assertEquals("whitlocktech", dto.account)
|
assertEquals("whitlocktech", dto.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Protocol 3.0 additions to char.profile ───────────────────────────
|
||||||
|
|
||||||
|
@Test fun charProfileDecodesThePointsBlock() {
|
||||||
|
// Shaped like a real shard's reply: an uncapped board (maxPoints 0), a
|
||||||
|
// cliloc-named board (nameString null), and no `rank` unless opted in.
|
||||||
|
val dto = json.decodeFromString<CharProfileDto>(
|
||||||
|
"""{"serial":"0x24C","name":"Darrow",
|
||||||
|
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty",
|
||||||
|
"points":29500,"maxPoints":30000,"rank":3},
|
||||||
|
{"system":"VoidPool","nameString":null,"points":180,"maxPoints":0}]}""",
|
||||||
|
)
|
||||||
|
assertEquals(2, dto.points.size)
|
||||||
|
val queens = dto.points[0]
|
||||||
|
assertEquals("Queen's Loyalty", queens.nameString)
|
||||||
|
assertEquals(29500L, queens.points)
|
||||||
|
assertEquals(30000L, queens.cap)
|
||||||
|
assertEquals(3, queens.rank)
|
||||||
|
|
||||||
|
val voidPool = dto.points[1]
|
||||||
|
assertNull("maxPoints 0 means uncapped, not a zero cap", voidPool.cap)
|
||||||
|
assertNull("rank is absent unless the shard opts in", voidPool.rank)
|
||||||
|
assertNull(voidPool.nameString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun charProfileWithoutAPointsBlockDecodesToEmpty() {
|
||||||
|
// A shard plugin that predates Protocol 3.0 sends no `points` key at all.
|
||||||
|
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x24C","name":"Darrow"}""")
|
||||||
|
assertEquals(emptyList<CharPointsDto>(), dto.points)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun equipmentDecodesTheServerResolvedClilocName() {
|
||||||
|
val dto = json.decodeFromString<CharProfileDto>(
|
||||||
|
"""{"serial":"0x24C",
|
||||||
|
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"cliloc":1023721,
|
||||||
|
"clilocName":"hatchet"},
|
||||||
|
{"serial":"0x41","layer":"Shirt","name":"Bob's lucky shirt",
|
||||||
|
"clilocName":"fancy shirt"}]}""",
|
||||||
|
)
|
||||||
|
assertEquals("hatchet", dto.equipment[0].label)
|
||||||
|
assertEquals("Bob's lucky shirt", dto.equipment[1].label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun titlesDecodeTheParallelResolvedArrayIncludingItsNulls() {
|
||||||
|
// rewardResolved carries a null where the cliloc table had nothing; the array
|
||||||
|
// must stay positionally aligned with `reward`.
|
||||||
|
val dto = json.decodeFromString<TitlesDto>(
|
||||||
|
"""{"selected":1,"reward":["1049565","1049566"],
|
||||||
|
"rewardResolved":[null,"Knight of Trinsic"]}""",
|
||||||
|
)
|
||||||
|
assertEquals(listOf("1049565", "1049566"), dto.reward)
|
||||||
|
assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String>` 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<AtlasCreatureDto>(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<AtlasCreatureDto>(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<AtlasCreatureDto>("""{"slug":"orc"}""")
|
||||||
|
assertEquals("orc", lean.slug)
|
||||||
|
assertTrue(lean.places.isEmpty())
|
||||||
|
assertTrue(lean.spawners.isEmpty())
|
||||||
|
assertNull(lean.art)
|
||||||
|
|
||||||
|
val bare = json.decodeFromString<AtlasCreatureDto>("""{"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<MarketListingDto>(
|
||||||
|
"""{"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<MarketListingDto>(
|
||||||
|
"""{"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<PointsBoardDto>(
|
||||||
|
"""{"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<RulesetDto>(
|
||||||
|
"""{"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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,4 +111,30 @@ class ShardDtoTest {
|
|||||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||||
assertEquals("Someone", ActorDto().label)
|
assertEquals("Someone", ActorDto().label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun actorArrivesWithoutAcctOrWebIdBelowTheAdminRung() {
|
||||||
|
// Those two fields are locked to `admin` by the visibility framework and are
|
||||||
|
// stripped from every response below it — the app must decode their absence,
|
||||||
|
// not depend on them (docs/link/v3.md §3.4 rule 1).
|
||||||
|
val dto = json.decodeFromString<ActorDto>("""{"serial":"0x24C","name":"Darrow","player":true}""")
|
||||||
|
assertEquals("Darrow", dto.label)
|
||||||
|
assertNull(dto.acct)
|
||||||
|
assertNull(dto.webId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shardFeaturesDecodesTheRungAndVisibleSet() {
|
||||||
|
val dto = json.decodeFromString<ShardFeaturesDto>(
|
||||||
|
"""{"level":"player","features":["status","champs","guilds","market"]}""",
|
||||||
|
)
|
||||||
|
assertEquals("player", dto.level)
|
||||||
|
assertTrue(dto.features.contains("market"))
|
||||||
|
assertEquals(4, dto.features.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shardFeaturesDecodesAnEmptySet() {
|
||||||
|
// A fully-gated shard: every feature switched off for this viewer. Distinct
|
||||||
|
// from the lookup failing, which the repository represents as null.
|
||||||
|
val dto = json.decodeFromString<ShardFeaturesDto>("""{"level":"anonymous","features":[]}""")
|
||||||
|
assertEquals(emptyList<String>(), dto.features)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.PostDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.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.ShardStatusDto
|
||||||
import com.runicgateway.app.data.api.dto.StatusDto
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
@@ -56,6 +65,25 @@ class FakePublicApi : PublicApi {
|
|||||||
var governors: List<GovernorDto> = emptyList()
|
var governors: List<GovernorDto> = emptyList()
|
||||||
var governorHistory: List<GovernorTermDto> = emptyList()
|
var governorHistory: List<GovernorTermDto> = emptyList()
|
||||||
var houses: List<HouseDto> = emptyList()
|
var houses: List<HouseDto> = emptyList()
|
||||||
|
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
||||||
|
|
||||||
|
// 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<PointsBoardDto> = 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). */
|
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
|
||||||
var lastContact: ContactRequest? = null
|
var lastContact: ContactRequest? = null
|
||||||
@@ -84,6 +112,41 @@ class FakePublicApi : PublicApi {
|
|||||||
return reply(contactResponse)
|
return reply(contactResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
|
||||||
|
override suspend fun getShardRuleset(): RulesetDto? = reply(ruleset)
|
||||||
|
override suspend fun getShardPoints(): List<PointsBoardDto> = 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 getShardStatus(): ShardStatusDto = reply(shardStatus)
|
||||||
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
|
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
|
||||||
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
|
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
package com.runicgateway.app.ui
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.ui.components.isRetryable
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -34,6 +36,44 @@ class UiStateTest {
|
|||||||
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
|
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shard reads: 404/403 mean "this shard doesn't publish it" (M11) ──
|
||||||
|
|
||||||
|
@Test fun shardReadsTreat404And403AsFeatureUnavailable() {
|
||||||
|
// requireFeature answers 404 for a disabled feature (deliberately not
|
||||||
|
// disclosing that it exists) and 403 for a viewer below its audience rung.
|
||||||
|
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(404))
|
||||||
|
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(403))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shardReadsLeaveEveryOtherStatusAlone() {
|
||||||
|
assertEquals(ErrorKind.SHARD_OFFLINE, shardKindOf(503))
|
||||||
|
assertEquals(ErrorKind.RATE_LIMITED, shardKindOf(429))
|
||||||
|
assertEquals(ErrorKind.SERVER, shardKindOf(500))
|
||||||
|
assertEquals(
|
||||||
|
ErrorKind.NETWORK,
|
||||||
|
(ApiResult.NetworkError(IOException()).toShardUiState() as UiState.Error).kind,
|
||||||
|
)
|
||||||
|
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toShardUiState())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nonShardReadsKeep404AsNotFound() {
|
||||||
|
// The remap is scoped to shard routes on purpose: off them, a 404 is still a
|
||||||
|
// deleted post or an unknown wiki slug.
|
||||||
|
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUnavailableFeatureIsNotRetryable() {
|
||||||
|
// An admin controls this, so a retry button would read as a transient failure
|
||||||
|
// the user could wait out.
|
||||||
|
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
|
||||||
|
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
|
||||||
|
assertTrue("$kind should offer a retry", isRetryable(kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun kindOf(status: Int): ErrorKind =
|
private fun kindOf(status: Int): ErrorKind =
|
||||||
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
||||||
|
|
||||||
|
private fun shardKindOf(status: Int): ErrorKind =
|
||||||
|
(ApiResult.HttpError(status).toShardUiState() as UiState.Error).kind
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.player
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.CharPointsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||||
import com.runicgateway.app.data.api.dto.TitlesDto
|
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
|
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
|
||||||
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal*
|
* website's `CharacterSheet.jsx`: title selection over the server's cliloc-resolved
|
||||||
* selected reward title, dropping bare cliloc numbers the app can't resolve.
|
* parallel array, item naming precedence, and the Protocol 3.0 points block.
|
||||||
*/
|
*/
|
||||||
class CharacterSheetHelpersTest {
|
class CharacterSheetHelpersTest {
|
||||||
|
|
||||||
@@ -51,4 +55,100 @@ class CharacterSheetHelpersTest {
|
|||||||
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
|
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
|
||||||
assertEquals(listOf("The Great"), displayTitles(titles))
|
assertEquals(listOf("The Great"), displayTitles(titles))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cliloc-resolved titles (Protocol 3.0 §8.6) ───────────────────────
|
||||||
|
|
||||||
|
@Test fun displayTitlesPrefersTheServerResolvedRewardName() {
|
||||||
|
// The website resolves the numeric entries against its own cliloc table and
|
||||||
|
// sends a parallel array; the raw number is no longer the only thing we have.
|
||||||
|
val titles = TitlesDto(
|
||||||
|
selected = 0,
|
||||||
|
reward = listOf("1049565"),
|
||||||
|
rewardResolved = listOf("Knight of Trinsic"),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesKeepsSelectedAlignedWhenAnEntryDoesNotResolve() {
|
||||||
|
// rewardResolved is POSITIONAL. An entry the table had nothing for is null and
|
||||||
|
// must be skipped WITHOUT shifting `selected` onto its neighbour — otherwise
|
||||||
|
// the sheet confidently shows the wrong title.
|
||||||
|
val titles = TitlesDto(
|
||||||
|
selected = 1,
|
||||||
|
reward = listOf("1049565", "1049566"),
|
||||||
|
rewardResolved = listOf(null, "Knight of Trinsic"),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesFallsBackWhenTheSelectedTitleDidNotResolve() {
|
||||||
|
val titles = TitlesDto(
|
||||||
|
selected = 0,
|
||||||
|
reward = listOf("1049565", "1049566"),
|
||||||
|
rewardResolved = listOf(null, "Bane of Dragons"),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("Bane of Dragons"), displayTitles(titles))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesStillSkipsNumbersWhenNothingResolved() {
|
||||||
|
// A shard that configures no cliloc table sends no rewardResolved at all —
|
||||||
|
// the pre-3.0 behavior, unchanged.
|
||||||
|
val titles = TitlesDto(selected = 0, reward = listOf("1049565"), rewardResolved = emptyList())
|
||||||
|
assertEquals(emptyList<String>(), displayTitles(titles))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Equipment names ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun itemLabelPrefersAPlayerGivenNameOverTheResolvedTypeName() {
|
||||||
|
// "Bob's lucky axe" must not be relabelled "hatchet".
|
||||||
|
val item = EquipmentDto(layer = "OneHanded", name = "Bob's lucky axe", clilocName = "hatchet")
|
||||||
|
assertEquals("Bob's lucky axe", item.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun itemLabelFallsBackThroughClilocNameThenLayer() {
|
||||||
|
assertEquals("hatchet", EquipmentDto(layer = "OneHanded", clilocName = "hatchet").label)
|
||||||
|
assertEquals("OneHanded", EquipmentDto(layer = "OneHanded").label)
|
||||||
|
assertNull(EquipmentDto().label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loyalty & points (Protocol 3.0 §7.3) ─────────────────────────────
|
||||||
|
|
||||||
|
@Test fun pointsLabelUsesTheHumanisedKeyWhenTheNameIsACliloc() {
|
||||||
|
// The PRIMARY path on a real shard: most systems name themselves with a
|
||||||
|
// cliloc, so nameString comes back null.
|
||||||
|
assertEquals("Queens Loyalty", pointsLabel(CharPointsDto(system = "QueensLoyalty")))
|
||||||
|
assertEquals("Clean Up Britannia", pointsLabel(CharPointsDto(system = "CleanUpBritannia")))
|
||||||
|
assertEquals("Void Pool", pointsLabel(CharPointsDto(system = "VoidPool")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pointsLabelPrefersTheShardsOwnNameWhenItHasOne() {
|
||||||
|
val entry = CharPointsDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")
|
||||||
|
assertEquals("Queen's Loyalty", pointsLabel(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUncappedSystemReportsNoCap() {
|
||||||
|
// maxPoints 0 means UNCAPPED and is the common case — three of five live
|
||||||
|
// boards on a real shard. Nothing may divide by it.
|
||||||
|
assertNull(CharPointsDto(points = 900, maxPoints = 0).cap)
|
||||||
|
assertNull(CharPointsDto(points = 900, maxPoints = null).cap)
|
||||||
|
assertEquals(30000L, CharPointsDto(points = 900, maxPoints = 30000).cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayPointsDropsZeroesAndSortsByStandingDescending() {
|
||||||
|
val char = CharProfileDto(
|
||||||
|
points = listOf(
|
||||||
|
CharPointsDto(system = "A", points = 10),
|
||||||
|
CharPointsDto(system = "Zero", points = 0),
|
||||||
|
CharPointsDto(system = "B", points = 500),
|
||||||
|
CharPointsDto(system = "Null", points = null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("B", "A"), displayPoints(char).map { it.system })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayPointsIsEmptyForAProfileWithNoPointsBlock() {
|
||||||
|
// A pre-3.0 shard plugin sends none, and a new character has earned nothing —
|
||||||
|
// both render as nothing at all rather than an empty card.
|
||||||
|
assertEquals(emptyList<CharPointsDto>(), displayPoints(CharProfileDto()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.runicgateway.app.data.api.dto.PresenceDto
|
|||||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||||
import com.runicgateway.app.data.api.fake.FakeShardStream
|
import com.runicgateway.app.data.api.fake.FakeShardStream
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
@@ -39,6 +40,10 @@ class ShardBoardViewModelTest {
|
|||||||
|
|
||||||
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
|
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
|
||||||
|
|
||||||
|
// The hub reads the feature set only to filter its board tiles; these tests
|
||||||
|
// exercise loading, so the answer stays at its "unknown" default (show all).
|
||||||
|
private fun features() = ShardFeaturesRepository(api)
|
||||||
|
|
||||||
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
|
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
|
||||||
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
|
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
|
||||||
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
|
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
|
||||||
@@ -108,7 +113,7 @@ class ShardBoardViewModelTest {
|
|||||||
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
|
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val vm = ShardViewModel(repo(stream))
|
val vm = ShardViewModel(repo(stream), features())
|
||||||
val hub = (vm.state.value as UiState.Success).data
|
val hub = (vm.state.value as UiState.Success).data
|
||||||
assertTrue(hub.status.isOnline)
|
assertTrue(hub.status.isOnline)
|
||||||
// presence.online frame patched the count in place.
|
// presence.online frame patched the count in place.
|
||||||
@@ -118,6 +123,6 @@ class ShardBoardViewModelTest {
|
|||||||
|
|
||||||
@Test fun shardHubStatusErrorIsUiError() {
|
@Test fun shardHubStatusErrorIsUiError() {
|
||||||
api.error = httpError(503)
|
api.error = httpError(503)
|
||||||
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
|
assertTrue(ShardViewModel(repo(), features()).state.value is UiState.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user