From 96aae7239b826da061a7f5a1429dca3819acd1ca Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 17:48:22 -0500 Subject: [PATCH] feat(rust): chat titles on the leaderboard (module-rust phase 17, M19) Each leaderboard row carries `titles: [{ text, color }]` from a phase-17 module: the chat titles the player holds now. Drawn as chips under the name, in the operator's colour with black or white ink chosen by WCAG contrast (the website's rule). Absent from an older module, so the field defaults to empty and nothing is drawn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../runicgateway/app/data/api/dto/RustDto.kt | 14 +++++ .../runicgateway/app/ui/rust/RustFormat.kt | 28 +++++++++ .../app/ui/rust/RustServerScreen.kt | 61 ++++++++++++++++--- .../runicgateway/app/ui/rust/RustDtoTest.kt | 25 ++++++++ .../app/ui/rust/RustFormatTest.kt | 16 +++++ 5 files changed, 137 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt index adc7b2c..dc63720 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -176,6 +176,20 @@ data class RustLeaderboardRowDto( val structures: Int = 0, val playtimeSec: Long = 0, val lastSeen: String? = null, + /** + * The chat titles this player holds now (module-rust phase 17, M19) — the + * ones the game shows in chat, after the server's mode. They rank the + * CURRENT wipe whichever wipe the board is showing. Absent from an older + * module, which is why it defaults to empty: nothing is drawn. + */ + val titles: List = emptyList(), +) + +/** One chat title: its text, and the operator's `#rrggbb` colour for it. */ +@Serializable +data class RustTitleDto( + val text: String = "", + val color: String = "", ) /** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */ diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt index 292202b..c18dd3b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -175,3 +175,31 @@ fun shortSteamId(steamId: String?): String { */ fun playerLabel(name: String?, steamId: String?): String = name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId) + +/** + * A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else — + * a title with a colour this build cannot read is drawn in the theme's own chip + * colours rather than guessed at (M19). + */ +fun titleArgb(hex: String?): Long? { + val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null + return 0xFF000000L or m.groupValues[1].toLong(16) +} + +/** + * Whether a title chip of this colour needs DARK ink — the same rule as the + * website's `contrastInk`: black or white, whichever has the higher WCAG + * contrast. The colour is the operator's, chosen for a dark game chat, and the + * app draws in the reader's theme, so the colour becomes the chip and the ink is + * picked for it. + */ +fun titleInkIsDark(argb: Long): Boolean { + fun linear(channel: Long): Double { + val v = channel / 255.0 + return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) + } + val l = 0.2126 * linear((argb shr 16) and 0xFF) + + 0.7152 * linear((argb shr 8) and 0xFF) + + 0.0722 * linear(argb and 0xFF) + return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index 95cc9d7..5552948 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -3,10 +3,13 @@ */ package com.runicgateway.app.ui.rust +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow @@ -25,6 +29,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -38,6 +44,7 @@ import com.runicgateway.app.data.api.dto.RustEventListDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustOnlineDto import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustTitleDto import com.runicgateway.app.data.api.dto.RustWipeDto import com.runicgateway.app.ui.ErrorKind import com.runicgateway.app.ui.PollWhileResumed @@ -392,6 +399,7 @@ private val RUST_COLUMNS = listOf( RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) }, ) +@OptIn(ExperimentalLayoutApi::class) @Composable private fun LeaderboardPanel( state: UiState>, @@ -452,13 +460,25 @@ private fun LeaderboardPanel( // Five numeric columns beside an equal-weight name column // left "Brannock" touching its own kill count, which the // walk read as one field. - Text( - text = playerLabel(row.name, row.steamId), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp), - ) + Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) { + Text( + text = playerLabel(row.name, row.steamId), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // M19: the chat titles this player holds now, under the + // name — the column is too narrow to put them beside it. + if (row.titles.isNotEmpty()) { + FlowRow( + Modifier.padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + row.titles.forEach { title -> TitleChip(title) } + } + } + } RUST_COLUMNS.forEach { column -> Text( text = column.value(row), @@ -475,6 +495,33 @@ private fun LeaderboardPanel( } } +/** + * One chat title (M19): the operator's colour as the chip, and black or white + * ink chosen for it, as the website draws it. A colour this build cannot read + * falls back to the theme's own chip colours. + */ +@Composable +private fun TitleChip(title: RustTitleDto) { + val argb = titleArgb(title.color) + val background = argb?.let { Color(it) } ?: MaterialTheme.colorScheme.secondaryContainer + val ink = when { + argb == null -> MaterialTheme.colorScheme.onSecondaryContainer + titleInkIsDark(argb) -> Color.Black + else -> Color.White + } + Text( + text = title.text, + style = MaterialTheme.typography.labelSmall, + color = ink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) +} + // ── Online ──────────────────────────────────────────────────────────────── @Composable diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt index 49803ce..a55316a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.rust import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustLeaderboardDto import com.runicgateway.app.data.api.dto.RustServerListDto import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals @@ -121,4 +122,28 @@ class RustDtoTest { assertNull(b.nextWipe) assertNull(c.nextWipe) } + + @Test + fun `a leaderboard row carries its chat titles, and an older module's carries none`() { + // M19. The shape is module-rust's `listLeaderboard`: `titles` on every + // row, empty for a player with none; absent altogether from a module + // older than phase 17, which must decode to empty rather than fail. + val board = json.decodeFromString( + """ + {"leaderboard":[ + {"steamId":"1","name":"Brannock","kills":12,"deaths":3,"npcKills":0,"structures":0,"playtimeSec":3600, + "titles":[{"text":"Top Killer","color":"#ff8800"},{"text":"Regular","color":"#00aa55"}]}, + {"steamId":"2","name":"Wren","kills":1,"deaths":0,"npcKills":0,"structures":0,"playtimeSec":60,"titles":[]} + ]} + """.trimIndent(), + ) + assertEquals(listOf("Top Killer", "Regular"), board.leaderboard[0].titles.map { it.text }) + assertEquals("#ff8800", board.leaderboard[0].titles[0].color) + assertTrue(board.leaderboard[1].titles.isEmpty()) + + val older = json.decodeFromString( + """{"leaderboard":[{"steamId":"1","name":"Brannock","kills":12}]}""", + ) + assertTrue(older.leaderboard.single().titles.isEmpty()) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt index b11de19..35a2427 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.rust import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -153,4 +154,19 @@ class RustFormatTest { assertNull(nextWipeWhen(null, now, utc, uk)) assertNull(nextWipeWhen("soon", now, utc, uk)) } + + @Test + fun `a title colour is read only as #rrggbb, and its ink is whichever reads`() { + assertEquals(0xFFFFAA55L, titleArgb("#ffaa55")) + assertEquals(0xFFFFAA55L, titleArgb("#FFAA55")) + assertNull(titleArgb("red")) + assertNull(titleArgb(null)) + + // The same four the website's contrastInk test asserts, so the two + // clients draw a title the same way. + assertTrue(titleInkIsDark(titleArgb("#ffff00")!!)) + assertTrue(titleInkIsDark(titleArgb("#ffaa55")!!)) + assertTrue(titleInkIsDark(titleArgb("#ff0000")!!)) + assertFalse(titleInkIsDark(titleArgb("#1a1a8c")!!)) + } }