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>
This commit is contained in:
2026-07-30 02:50:19 -05:00
parent 833e51de69
commit aacef35def
18 changed files with 2337 additions and 0 deletions

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,172 @@
/*
* 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.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 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)
}
}