feat(rust): chat titles on the leaderboard (module-rust phase 17, M19) #53

Merged
whitlocktech merged 1 commits from feat/rust-phase-17-titles into edge 2026-09-25 23:31:57 +00:00
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")!!))
}
}