5 Commits

Author SHA1 Message Date
5eaf5d22c6 Merge pull request 'feat(shard): follow the visibility framework and read the Protocol 3.0 profile' (#30) from feat/protocol-3-visibility into main
All checks were successful
sync-project-tree / sync (push) Successful in 20s
SonarQube / analysis (push) Successful in 5m14s
Release APK / release (push) Successful in 10m13s
Reviewed-on: #30
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 07:22:05 +00:00
12b2172731 Merge pull request 'fix(shard): decode the atlas places objects and render them' (#32) from fix/atlas-places-decode into feat/protocol-3-visibility
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
Reviewed-on: #32
2026-08-01 06:03:51 +00:00
4f85021be2 fix(shard): decode the atlas places objects and render them
`AtlasCreatureDto.places` was typed `List<String>` while the server sends
`{facet, label, spawners, maxAlive}` objects. The detail route answers 200 with
~49 KB, kotlinx throws on decode, and the screen renders "Something went wrong
on the server" — so the whole Atlas creature page was dead, and the error
blamed a server that was fine. Nullable-with-defaults protects against a
missing field, never a wrong element type.

Adds AtlasPlaceDto, plus the `art` field the server also sends, so a decode
cannot depend on that staying absent (neither client renders art yet).

`places` was never rendered either, so the aggregate the atlas exists to give —
"Shrines, Isamu-Jima, Yew", resolved server-side by point-in-rect — was missing
from the app while the web page led with it. Adds a "Where it spawns" section
above the individual spawners, matching web's ordering, and a plural for the
spawner count now that single-spawner places are on screen in bulk.

Adds ShardContentDtoTest — the first decode test any of the four Protocol 3.0
DTOs has had, fed payloads captured from a live server. That absence is the
root cause: the fakes in data/api/fake/ construct DTOs in Kotlin, so no test in
the suite could see a wire mismatch, even though PLAN.md §9 already required
"DTO decode for each new shape".

Also renders a placeholder row on an unscored leaderboard (the instance name,
em dash where a score goes) rather than a blank card — deliberately not shaped
like a real entry, since a placeholder that looked like a standing would be a
fabricated one.

Found by the on-device five-rung walk against a live shard; all four screens
re-verified on the emulator afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:59:23 -05:00
06b6b015c2 Merge pull request 'feat(shard): the four Protocol 3.0 content screens' (#31) from feat/protocol-3-screens into feat/protocol-3-visibility
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m26s
Reviewed-on: #31
2026-07-30 07:54:27 +00:00
aacef35def feat(shard): the four Protocol 3.0 content screens
M11 Part 2 (docs/android/PLAN.md §9), on the visibility plumbing Part 1 added.
Each screen hides from the menu when the shard doesn't publish its feature, and
self-reports "not available here" from its own 404/403 so a deep link still
lands on an honest answer.

  - Rules (/public/shard/ruleset). A null body means the shard has never
    published a ruleset, which is a SUCCESS state, not the feature being off —
    the screen tells the two apart. Blocks render only when published, since an
    omitted block means the system is off rather than unknown. Skill caps are
    converted out of tenths; the raw 1000 reads as ten times the real limit.
    Live via world.ruleset, which the shard re-emits on every reconnect.
  - Leaderboards (/public/shard/points). Boards order most-contested first, live
    via points.board. maxPoints 0 is uncapped so no cap line is drawn, and a
    cliloc-named board (nameString null, the usual case) falls back to the
    humanised PointsType key. A nameless rank is a valid row: the character name
    is the feature's one admin-configurable field.
  - Market (/public/shard/market + /meta + /vendors/:serial). NOT live: the
    market feature ships with its SSE fan-out disabled, so this is a plain
    paginated read, searched on submit rather than per keystroke because it is
    the site's first rate-limited public endpoint. The staleness line is
    required, not decoration — the round-robin sweep means a price can be a full
    cycle old. The vendor screen is the only surface that can render a truncated
    shop and a gated location, the latter as a real answer rather than a blank
    coordinate.
  - Atlas (/public/atlas/creatures[/:slug]). Static shard content, so it stays
    readable while the shard is down — but site-mode gated, unlike /shard/*.
    Rows lead with the server's placement label ("Despise, Felucca"), which is
    the transform the whole feature exists for. Respawn delays are read as
    SECONDS, the unit the parser normalises XmlSpawner's mixed minutes/seconds
    into. Facet filter options are discovered from the shard's own data — nothing
    here names a facet, since a shard may add, replace or rename them.

336 unit tests pass (32 new); lint clean. The five-rung on-device walk runs
against a local website on the cutover branch before the cutover merges.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 02:50:19 -05:00
19 changed files with 2618 additions and 0 deletions

View File

@@ -3,6 +3,9 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.AtlasMetaDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
@@ -12,10 +15,15 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
@@ -137,4 +145,65 @@ interface PublicApi {
@GET("api/v1/public/shard/houses")
suspend fun getShardHouses(): List<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
}

View File

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

View File

@@ -8,6 +8,8 @@ import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
@@ -15,8 +17,13 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.KSerializer
@@ -62,6 +69,59 @@ class ShardRepository @Inject constructor(
suspend fun houses(): ApiResult<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 ──────────────────────────────────────────────────────
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
@@ -73,9 +133,27 @@ class ShardRepository @Inject constructor(
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
// Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by
// default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out
// disabled (a live firehose of vendor inventories would be the site's biggest
// bandwidth consumer), so the market screen is a plain paginated read and must never
// wait on a frame.
fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer())
fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer())
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
json.decodeFromJsonElement(serializer, obj)
} catch (_: Exception) {
null
}
companion object {
const val SORT_PRICE_ASC = "price_asc"
const val SORT_PRICE_DESC = "price_desc"
const val SORT_RECENT = "recent"
/** The server caps `limit` at 100; stay well under it on a phone. */
const val MARKET_PAGE = 50
const val ATLAS_PAGE = 50
}
}

View File

@@ -72,10 +72,16 @@ import com.runicgateway.app.ui.player.CharactersScreen
import com.runicgateway.app.ui.player.MyHousesScreen
import com.runicgateway.app.ui.player.VendorsScreen
import com.runicgateway.app.ui.session.SessionViewModel
import com.runicgateway.app.ui.shard.AtlasCreatureScreen
import com.runicgateway.app.ui.shard.AtlasScreen
import com.runicgateway.app.ui.shard.ChampsScreen
import com.runicgateway.app.ui.shard.GovernorsScreen
import com.runicgateway.app.ui.shard.GuildsScreen
import com.runicgateway.app.ui.shard.HousesScreen
import com.runicgateway.app.ui.shard.LeaderboardsScreen
import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.wiki.WikiPageScreen
@@ -85,6 +91,9 @@ import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
// Protocol 3.0 content screens are drawer destinations, so the drawer gesture works
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
@@ -305,6 +314,30 @@ private fun RunicNavHost(
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
composable(Routes.SHARD_HOUSES) { HousesScreen() }
// Protocol 3.0 shard content (M11). Each screen self-reports "not published
// here" from its own 404/403, so a deep link to a gated feature still lands on
// an honest answer even though the menu hides the entry.
composable(Routes.SHARD_RULES) { RulesScreen() }
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
composable(Routes.SHARD_MARKET) {
MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
}
composable(
route = Routes.SHARD_MARKET_VENDOR,
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
) { entry ->
MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty())
}
composable(Routes.ATLAS) {
AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) })
}
composable(
route = Routes.ATLAS_CREATURE,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) { entry ->
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}

View File

@@ -64,6 +64,12 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
// which for a brand-new install is every one of them until the plugin has swept.
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),

View File

@@ -34,6 +34,18 @@ object Routes {
const val SHARD_GOVERNORS = "shard/governors"
const val SHARD_HOUSES = "shard/houses"
/**
* Protocol 3.0 shard content (M11), each gated by its own visibility feature. The
* atlas is not under `shard/` on the wire (`/public/atlas`) because it is static
* content rather than live state, but it is a peer of these in the app's nav.
*/
const val SHARD_RULES = "shard/rules"
const val SHARD_LEADERBOARDS = "shard/leaderboards"
const val SHARD_MARKET = "shard/market"
const val SHARD_MARKET_VENDOR = "shard/market/{serial}"
const val ATLAS = "atlas"
const val ATLAS_CREATURE = "atlas/{slug}"
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
const val PLAYER_CHARACTERS = "player/characters"
const val PLAYER_VENDORS = "player/vendors"
@@ -72,6 +84,12 @@ object Routes {
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
fun playerChar(serial: String) = "player/char/$serial"
/** One player vendor's shop, by in-game (hex) serial. */
fun marketVendor(serial: String) = "shard/market/$serial"
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"
/**
* The in-app destination a tapped push notification deep-links to (§11, M7
* Part 2 work item 7). Maps a stream id to the screen that shows its content;

View 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 }
}

View File

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

View File

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

View File

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

View 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
}
}

View File

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

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

View File

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

View File

@@ -41,6 +41,10 @@
<string name="menu_news">News</string>
<string name="menu_wiki">Wiki</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_contact">Contact</string>
<string name="menu_account">My account</string>
@@ -382,6 +386,85 @@
<string name="guilds_leader">Led by %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 &amp; stat caps</string>
<string name="rules_section_accounts">Accounts &amp; housing</string>
<string name="rules_section_vendors">Vendors</string>
<string name="rules_section_schedule">Saves &amp; 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) ────────────────────────────────────────────── -->
<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>

View File

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

View File

@@ -18,6 +18,14 @@ import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.AtlasMetaDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
@@ -59,6 +67,24 @@ class FakePublicApi : PublicApi {
var houses: List<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). */
var lastContact: ContactRequest? = null
@@ -87,6 +113,40 @@ class FakePublicApi : PublicApi {
}
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
override suspend fun getShardRuleset(): RulesetDto? = reply(ruleset)
override suspend fun getShardPoints(): List<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 getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)

View File

@@ -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("5m10m", 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")))
}
}

View File

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