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 structures: Int = 0,
|
||||||
val playtimeSec: Long = 0,
|
val playtimeSec: Long = 0,
|
||||||
val lastSeen: String? = null,
|
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. */
|
/** `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 =
|
fun playerLabel(name: String?, steamId: String?): String =
|
||||||
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
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
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
@@ -25,6 +29,8 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
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.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.RustLeaderboardRowDto
|
||||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
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.data.api.dto.RustWipeDto
|
||||||
import com.runicgateway.app.ui.ErrorKind
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
import com.runicgateway.app.ui.PollWhileResumed
|
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) },
|
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LeaderboardPanel(
|
private fun LeaderboardPanel(
|
||||||
state: UiState<List<RustLeaderboardRowDto>>,
|
state: UiState<List<RustLeaderboardRowDto>>,
|
||||||
@@ -452,13 +460,25 @@ private fun LeaderboardPanel(
|
|||||||
// Five numeric columns beside an equal-weight name column
|
// Five numeric columns beside an equal-weight name column
|
||||||
// left "Brannock" touching its own kill count, which the
|
// left "Brannock" touching its own kill count, which the
|
||||||
// walk read as one field.
|
// walk read as one field.
|
||||||
Text(
|
Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) {
|
||||||
text = playerLabel(row.name, row.steamId),
|
Text(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = playerLabel(row.name, row.steamId),
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = 1,
|
||||||
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
|
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 ->
|
RUST_COLUMNS.forEach { column ->
|
||||||
Text(
|
Text(
|
||||||
text = column.value(row),
|
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 ────────────────────────────────────────────────────────────────
|
// ── Online ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.rust
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
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 com.runicgateway.app.data.api.dto.RustServerListDto
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -121,4 +122,28 @@ class RustDtoTest {
|
|||||||
assertNull(b.nextWipe)
|
assertNull(b.nextWipe)
|
||||||
assertNull(c.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
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -153,4 +154,19 @@ class RustFormatTest {
|
|||||||
assertNull(nextWipeWhen(null, now, utc, uk))
|
assertNull(nextWipeWhen(null, now, utc, uk))
|
||||||
assertNull(nextWipeWhen("soon", 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