feat(rust): chat titles on the leaderboard (module-rust phase 17, M19) #53
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")!!))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user