feat(rust): chat titles on the leaderboard (module-rust phase 17, M19)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m38s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 17:48:22 -05:00
parent 341c13a98c
commit 96aae7239b
5 changed files with 137 additions and 7 deletions

View File

@@ -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<RustTitleDto> = 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. */

View File

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

View File

@@ -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<List<RustLeaderboardRowDto>>,
@@ -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

View File

@@ -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<RustLeaderboardDto>(
"""
{"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<RustLeaderboardDto>(
"""{"leaderboard":[{"steamId":"1","name":"Brannock","kills":12}]}""",
)
assertTrue(older.leaderboard.single().titles.isEmpty())
}
}

View File

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