diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/ServerDisplayQuery.kt b/api/src/main/kotlin/gg/grounds/proxy/api/ServerDisplayQuery.kt
new file mode 100644
index 0000000..9d6c431
--- /dev/null
+++ b/api/src/main/kotlin/gg/grounds/proxy/api/ServerDisplayQuery.kt
@@ -0,0 +1,20 @@
+package gg.grounds.proxy.api
+
+/**
+ * How a backend should be named in player-facing UI.
+ *
+ * plugin-agones knows the Agones GameServer name and the `grounds/server-type` label. plugin-proxy
+ * draws the footer and must not import Agones types, so it asks the registry.
+ *
+ * With nothing registered, callers still show [ServerDisplay.id] parsed from the Velocity server
+ * name and omit the kind, rather than printing the full pod name.
+ */
+interface ServerDisplayQuery {
+ fun displayOf(serverName: String): ServerDisplay?
+}
+
+/**
+ * @param kind `grounds/server-type` value, e.g. `lobby`
+ * @param id last `-` segment of the GameServer name, e.g. `s9fwt`
+ */
+data class ServerDisplay(val kind: String, val id: String)
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt
index e71d10f..f8b8b61 100644
--- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt
@@ -18,6 +18,7 @@ import gg.grounds.proxy.api.PlayerLocaleQuery
import gg.grounds.proxy.api.PlayerRoleQuery
import gg.grounds.proxy.api.ProxyService
import gg.grounds.proxy.api.ProxyServiceRegistry
+import gg.grounds.proxy.api.ServerDisplayQuery
import gg.grounds.proxy.velocity.command.MotdCommand
import gg.grounds.proxy.velocity.command.OnlineCommand
import gg.grounds.proxy.velocity.command.RegionCommand
@@ -183,9 +184,13 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
// Looked up per call, not captured: plugin-permissions may register after this runs, and a
// reference taken now would stay null for the life of the proxy.
val tab =
- TabList(proxy, messages, region) {
- ProxyServiceRegistry.get(PlayerRoleQuery::class.java)
- }
+ TabList(
+ proxy,
+ messages,
+ roleQuery = { ProxyServiceRegistry.get(PlayerRoleQuery::class.java) },
+ localeQuery = { ProxyServiceRegistry.get(PlayerLocaleQuery::class.java) },
+ serverQuery = { ProxyServiceRegistry.get(ServerDisplayQuery::class.java) },
+ )
tabList = tab
// On a timer as well as on join: the ping and the roster both change with no event to hang
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ProxyMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ProxyMessage.kt
index a9e03ab..4fbc1ca 100644
--- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ProxyMessage.kt
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ProxyMessage.kt
@@ -11,4 +11,7 @@ import gg.grounds.i18n.MessageKey
enum class ProxyMessage(override val id: String) : MessageKey {
TAB_HEADER("tab.header"),
TAB_FOOTER("tab.footer"),
+ TAB_SERVER_LOBBY("tab.server.lobby"),
+ TAB_SERVER_GAME("tab.server.game"),
+ TAB_SERVER_MATCH("tab.server.match"),
}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ServerDisplayIds.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ServerDisplayIds.kt
new file mode 100644
index 0000000..2f96abe
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/ServerDisplayIds.kt
@@ -0,0 +1,8 @@
+package gg.grounds.proxy.velocity.tab
+
+object ServerDisplayIds {
+ fun idOf(serverName: String): String {
+ val id = serverName.substringAfterLast('-')
+ return if (id.isEmpty()) serverName else id
+ }
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt
new file mode 100644
index 0000000..daa4077
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt
@@ -0,0 +1,37 @@
+package gg.grounds.proxy.velocity.tab
+
+import kotlin.math.max
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+import net.kyori.adventure.text.format.TextColor
+
+object TabBadge {
+ fun chip(label: String, fill: TextColor): Component {
+ val textWidth = VanillaAdvances.width(label)
+ val pad = 4
+ val inner =
+ max(textWidth + pad, TabGlyphs.LEFT_PX + TabGlyphs.RIGHT_PX + TabGlyphs.MIDDLE_PX)
+ val middles = inner - TabGlyphs.LEFT_PX - TabGlyphs.RIGHT_PX
+ val badgeWidth = TabGlyphs.LEFT_PX + middles * TabGlyphs.MIDDLE_PX + TabGlyphs.RIGHT_PX
+ val padLeft = (badgeWidth - textWidth) / 2
+ val padRight = badgeWidth - textWidth - padLeft
+ val gap = TabSpaces.of(-1)
+ val slices = buildString {
+ append(TabGlyphs.BADGE_LEFT)
+ append(gap)
+ repeat(middles) {
+ append(TabGlyphs.BADGE_MIDDLE)
+ append(gap)
+ }
+ append(TabGlyphs.BADGE_RIGHT)
+ append(gap)
+ }
+ return Component.text()
+ .append(Component.text(slices, fill).font(TabGlyphs.FONT))
+ .append(Component.text(TabSpaces.of(-badgeWidth)).font(TabGlyphs.FONT))
+ .append(Component.text(TabSpaces.of(padLeft)).font(TabGlyphs.FONT))
+ .append(Component.text(label, NamedTextColor.WHITE))
+ .append(Component.text(TabSpaces.of(padRight)).font(TabGlyphs.FONT))
+ .build()
+ }
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabGlyphs.kt
new file mode 100644
index 0000000..9353c21
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabGlyphs.kt
@@ -0,0 +1,14 @@
+package gg.grounds.proxy.velocity.tab
+
+import net.kyori.adventure.key.Key
+
+object TabGlyphs {
+ val FONT: Key = Key.key("grounds", "tab")
+ const val LOGO = '\uE000'
+ const val BADGE_LEFT = '\uE001'
+ const val BADGE_MIDDLE = '\uE002'
+ const val BADGE_RIGHT = '\uE003'
+ const val LEFT_PX = 3
+ const val MIDDLE_PX = 1
+ const val RIGHT_PX = 3
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabList.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabList.kt
index df18e7c..2c542e7 100644
--- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabList.kt
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabList.kt
@@ -4,24 +4,29 @@ import com.velocitypowered.api.proxy.Player
import com.velocitypowered.api.proxy.ProxyServer
import gg.grounds.i18n.Palette
import gg.grounds.i18n.Translations
-import gg.grounds.proxy.api.PlayerRole
+import gg.grounds.proxy.api.PlayerLocaleQuery
import gg.grounds.proxy.api.PlayerRoleQuery
+import gg.grounds.proxy.api.ServerDisplayQuery
import java.time.Year
import net.kyori.adventure.text.Component
-import net.kyori.adventure.text.format.TextColor
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
/**
- * The header and footer above and below the player list, and the colour of the names in it.
+ * The header and footer above and below the player list, and the chips and colour of the names in
+ * it.
*
* The tab list is the only screen a player can open from anywhere, which makes it the right place
- * for the two facts that are true everywhere: which network this is, and where in it you are
- * standing. It belongs to the proxy because both answers do — a backend server knows its own name
- * and nothing about the region it sits in.
+ * for the two facts that are true everywhere: which network this is, and which backend you are on.
+ * It belongs to the proxy because both answers do — a backend server knows its own name and nothing
+ * about how the network should label it.
*
* ```
- * Grounds Network
+ * [GROUNDS wordmark]
*
- * Region nl-ams1 Ping 24 ms
+ * [DE] [ADMIN] Steve
+ * [EN] [USER] Alex
+ *
+ * Lobby s9fwt Ping 24 ms
* grounds.gg 2026
* ```
*
@@ -32,8 +37,9 @@ import net.kyori.adventure.text.format.TextColor
class TabList(
private val proxy: ProxyServer,
private val messages: Translations,
- private val region: () -> String?,
private val roleQuery: () -> PlayerRoleQuery?,
+ private val localeQuery: () -> PlayerLocaleQuery?,
+ private val serverQuery: () -> ServerDisplayQuery?,
) {
/** Redraws everything [viewer] sees. */
@@ -59,35 +65,56 @@ class TabList(
messages.render(
ProxyMessage.TAB_FOOTER,
viewer,
- "region" to (region() ?: UNKNOWN),
+ "server" to serverLabel(viewer),
"ping" to ping(viewer.ping),
"year" to Year.now().value.toString(),
)
+ private fun serverLabel(viewer: Player): Component {
+ val raw =
+ viewer.currentServer.map { it.serverInfo.name }.orElse(null)
+ ?: return Component.text(UNKNOWN, Palette.TEXT_FAINT)
+ val queried = serverQuery()?.displayOf(raw)
+ val id = queried?.id ?: ServerDisplayIds.idOf(raw)
+ val kind = queried?.kind
+ val kindLabel = kindLabel(kind, viewer)
+ val text = if (kindLabel == null) id else "$kindLabel $id"
+ return Component.text(text, Palette.TEXT)
+ }
+
+ private fun kindLabel(kind: String?, viewer: Player): String? {
+ val key =
+ when (kind) {
+ "lobby" -> ProxyMessage.TAB_SERVER_LOBBY
+ "game" -> ProxyMessage.TAB_SERVER_GAME
+ "match" -> ProxyMessage.TAB_SERVER_MATCH
+ else -> return kind
+ }
+ return plain(messages.render(key, viewer))
+ }
+
/**
- * Paints each name in the colour of its owner's highest role.
+ * Paints each name with language and rank chips, then the name in the role's colour.
*
- * A no-op until something registers a [PlayerRoleQuery] — plugin-permissions holds the snapshot
- * these colours come from. Until then every name keeps the backend's own display name, which is
- * what players see today.
+ * Locale comes from [PlayerLocaleQuery] when registered, otherwise the locale the client
+ * announced. Rank comes from [PlayerRoleQuery]. A missing query or a missing value omits that
+ * chip rather than drawing a placeholder. Display names are always set so a player with no rank
+ * still shows a language chip.
*/
private fun colourNames(viewer: Player) {
- val query = roleQuery() ?: return
+ val query = roleQuery()
+ val localeQ = localeQuery()
viewer.tabList.entries.forEach { entry ->
- val role = query.highestRoleOf(entry.profile.id) ?: return@forEach
- entry.setDisplayName(displayName(entry.profile.name, role))
+ val role = query?.highestRoleOf(entry.profile.id)
+ val locale =
+ localeQ?.localeOf(entry.profile.id)
+ ?: proxy.getPlayer(entry.profile.id).map { it.effectiveLocale }.orElse(null)
+ entry.setDisplayName(TabName.format(entry.profile.name, locale, role))
}
}
- private fun displayName(name: String, role: PlayerRole): Component {
- // A colour the service stores badly should cost that one player their colour, not throw on
- // a render that runs every few seconds for everybody.
- val colour = role.colour?.let(TextColor::fromHexString) ?: Palette.TEXT
- val prefix = role.prefix.orEmpty()
- return Component.empty()
- .append(Component.text(prefix, colour))
- .append(Component.text(name, colour))
- }
+ private fun plain(component: Component): String =
+ PlainTextComponentSerializer.plainText().serialize(component)
companion object {
private const val UNKNOWN = "—"
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabName.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabName.kt
new file mode 100644
index 0000000..f9400b8
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabName.kt
@@ -0,0 +1,30 @@
+package gg.grounds.proxy.velocity.tab
+
+import gg.grounds.i18n.Palette
+import gg.grounds.proxy.api.PlayerRole
+import java.util.Locale
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.TextColor
+
+object TabName {
+ fun format(name: String, locale: Locale?, role: PlayerRole?): Component {
+ val colour = role?.colour?.let(TextColor::fromHexString) ?: Palette.TEXT
+ val row = Component.text()
+ locale
+ ?.language
+ ?.takeIf { it.isNotBlank() }
+ ?.let { language ->
+ row.append(TabBadge.chip(language.uppercase(Locale.ROOT), Palette.TEXT_FAINT))
+ row.append(Component.text(TabSpaces.of(2)).font(TabGlyphs.FONT))
+ }
+ role
+ ?.name
+ ?.takeIf { it.isNotBlank() }
+ ?.let { rank ->
+ row.append(TabBadge.chip(rank.uppercase(Locale.ROOT), colour))
+ row.append(Component.text(TabSpaces.of(2)).font(TabGlyphs.FONT))
+ }
+ row.append(Component.text(name, colour))
+ return row.build()
+ }
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabSpaces.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabSpaces.kt
new file mode 100644
index 0000000..ec5f579
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabSpaces.kt
@@ -0,0 +1,44 @@
+package gg.grounds.proxy.velocity.tab
+
+/**
+ * The tab font's space glyphs: one codepoint per signed power of two, so any pixel offset in
+ * `-`[MAX_OFFSET]`..`[MAX_OFFSET] is expressed as a short string of existing glyphs.
+ *
+ * Starts at `U+E010` so it does not collide with the wordmark (`U+E000`) or the badge slices
+ * (`U+E001`–`U+E003`). The ladder itself matches library-gui `Spaces`.
+ */
+object TabSpaces {
+ const val PUA_START: Int = 0xE010
+
+ const val PUA_END: Int = 0xF8FF
+
+ private val STEPS = intArrayOf(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024)
+
+ const val MAX_OFFSET: Int = 2047
+
+ private fun codepoint(index: Int, negative: Boolean): Int =
+ PUA_START + index * 2 + if (negative) 1 else 0
+
+ /**
+ * The glyph string that moves the text cursor [px] pixels; negative moves left. Empty for `0`.
+ *
+ * The steps are powers of two and [px] is bounded, so this is a binary decomposition — each
+ * step appears at most once and the result is never longer than [STEPS]`.size` characters.
+ */
+ fun of(px: Int): String {
+ require(px >= -MAX_OFFSET && px <= MAX_OFFSET) {
+ "offset $px is outside +-$MAX_OFFSET, which the space ladder cannot express"
+ }
+ if (px == 0) return ""
+ val negative = px < 0
+ var remaining = if (negative) -px else px
+ val out = StringBuilder()
+ for (index in STEPS.indices.reversed()) {
+ if (remaining >= STEPS[index]) {
+ out.appendCodePoint(codepoint(index, negative))
+ remaining -= STEPS[index]
+ }
+ }
+ return out.toString()
+ }
+}
diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/VanillaAdvances.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/VanillaAdvances.kt
new file mode 100644
index 0000000..3465d2e
--- /dev/null
+++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/VanillaAdvances.kt
@@ -0,0 +1,18 @@
+package gg.grounds.proxy.velocity.tab
+
+object VanillaAdvances {
+ fun width(text: String): Int = text.sumOf { advance(it) }
+
+ private fun advance(ch: Char): Int =
+ when (ch) {
+ ' ' -> 4
+ 'I',
+ 't' -> 4
+ 'i',
+ '!' -> 2
+ 'l' -> 3
+ 'f',
+ 'k' -> 5
+ else -> 6
+ }
+}
diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties
index 13c5bd5..49278bf 100644
--- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties
+++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties
@@ -1,2 +1,5 @@
-tab.header=\nGrounds Network\n
-tab.footer=\nRegion Ping \ngrounds.gg
+tab.header=\n\uE000\n
+tab.footer=\n Ping \ngrounds.gg
+tab.server.lobby=Lobby
+tab.server.game=Game
+tab.server.match=Match
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ServerDisplayIdsTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ServerDisplayIdsTest.kt
new file mode 100644
index 0000000..b0acae4
--- /dev/null
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ServerDisplayIdsTest.kt
@@ -0,0 +1,21 @@
+package gg.grounds.proxy.velocity.tab
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class ServerDisplayIdsTest {
+ @Test
+ fun `the replica id is the last hyphen segment`() {
+ assertEquals("s9fwt", ServerDisplayIds.idOf("lobby-nl-ams1-tr9pf-s9fwt"))
+ }
+
+ @Test
+ fun `a name with no hyphen is used whole`() {
+ assertEquals("lobby", ServerDisplayIds.idOf("lobby"))
+ }
+
+ @Test
+ fun `a trailing hyphen does not produce an empty id`() {
+ assertEquals("lobby-", ServerDisplayIds.idOf("lobby-"))
+ }
+}
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabBadgeTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabBadgeTest.kt
new file mode 100644
index 0000000..1b35282
--- /dev/null
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabBadgeTest.kt
@@ -0,0 +1,37 @@
+package gg.grounds.proxy.velocity.tab
+
+import net.kyori.adventure.text.format.NamedTextColor
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class TabBadgeTest {
+ @Test
+ fun `a chip contains the overlay label`() {
+ val plain =
+ PlainTextComponentSerializer.plainText()
+ .serialize(TabBadge.chip("DE", NamedTextColor.WHITE))
+ assertTrue(plain.contains("DE"), plain)
+ }
+
+ @Test
+ fun `slice glyphs abut by cancelling the bitmap advance`() {
+ val plain =
+ PlainTextComponentSerializer.plainText()
+ .serialize(TabBadge.chip("DE", NamedTextColor.WHITE))
+ val gap = TabSpaces.of(-1)
+ val left = TabGlyphs.BADGE_LEFT
+ val middle = TabGlyphs.BADGE_MIDDLE
+ val right = TabGlyphs.BADGE_RIGHT
+ assertFalse(plain.contains("$left$middle"), plain)
+ assertFalse(plain.contains("$middle$middle"), plain)
+ assertFalse(plain.contains("$middle$right"), plain)
+ assertTrue(plain.contains("$left$gap$middle"), plain)
+ assertTrue(plain.contains("$middle$gap$middle"), plain)
+ assertTrue(plain.contains("$middle$gap$right$gap"), plain)
+ val badgeWidth = 16
+ assertTrue(plain.contains(TabSpaces.of(-badgeWidth)), plain)
+ assertFalse(plain.contains(TabSpaces.of(-(badgeWidth + 12))), plain)
+ }
+}
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabListTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabListTest.kt
index 6885d5a..5e41b28 100644
--- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabListTest.kt
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabListTest.kt
@@ -24,12 +24,12 @@ class TabListTest {
private fun colourOf(component: Component) =
component.color() ?: component.children().firstNotNullOfOrNull { it.color() }
- private fun footer(region: String, ping: Long) =
+ private fun footer(server: String, ping: Long) =
plain(
messages.render(
ProxyMessage.TAB_FOOTER,
Locale.ENGLISH,
- "region" to region,
+ "server" to server,
"ping" to TabList.ping(ping),
"year" to Year.now().value.toString(),
)
@@ -43,23 +43,25 @@ class TabListTest {
}
@Test
- fun `the header names the network`() {
+ fun `the header is the wordmark glyph`() {
val header = plain(messages.render(ProxyMessage.TAB_HEADER, Locale.ENGLISH))
- assertTrue(header.contains("Grounds Network"), header)
+ assertTrue(header.contains("\uE000"), header)
+ assertFalse(header.contains("Grounds Network"), header)
}
@Test
- fun `the header is not prefixed - it already says whose network this is`() {
+ fun `the header is not prefixed - the wordmark already says whose network this is`() {
val header = plain(messages.render(ProxyMessage.TAB_HEADER, Locale.ENGLISH))
assertFalse(header.contains("[Grounds]"), header)
}
@Test
- fun `the footer carries the region, the ping and the domain`() {
- val rendered = footer("nl-ams1", 24)
- assertTrue(rendered.contains("nl-ams1"), rendered)
+ fun `the footer carries the server, the ping and the domain`() {
+ val rendered = footer("Lobby s9fwt", 24)
+ assertTrue(rendered.contains("Lobby s9fwt"), rendered)
assertTrue(rendered.contains("24 ms"), rendered)
assertTrue(rendered.contains("grounds.gg ${Year.now().value}"), rendered)
+ assertFalse(rendered.contains("nl-ams1"), rendered)
}
@Test
@@ -110,6 +112,8 @@ class TabListTest {
val legacy = Regex("<(dark_)?(red|green|blue|aqua|purple|yellow|gray|grey|white|black)>")
val bundle = ResourceBundle.getBundle(BUNDLE, Locale.ENGLISH, javaClass.classLoader)
for (key in bundle.keySet()) {
+ // The wordmark is a bitmap glyph MiniMessage tints white; it is not body copy.
+ if (key == ProxyMessage.TAB_HEADER.id) continue
assertFalse(
legacy.containsMatchIn(bundle.getString(key)),
"$key uses a legacy colour instead of a semantic token",
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabNameTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabNameTest.kt
new file mode 100644
index 0000000..e9211e2
--- /dev/null
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabNameTest.kt
@@ -0,0 +1,33 @@
+package gg.grounds.proxy.velocity.tab
+
+import gg.grounds.proxy.api.PlayerRole
+import java.util.Locale
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class TabNameTest {
+ private fun plain(locale: Locale?, role: PlayerRole?) =
+ PlainTextComponentSerializer.plainText().serialize(TabName.format("Steve", locale, role))
+
+ @Test
+ fun `language and rank sit in front of the name`() {
+ val text =
+ plain(
+ Locale.GERMANY,
+ PlayerRole("admin", "Admin", prefix = "[Admin] ", colour = "#f9a49a", sortOrder = 0),
+ )
+ assertTrue(text.contains("DE"), text)
+ assertTrue(text.contains("ADMIN"), text)
+ assertTrue(text.contains("Steve"), text)
+ assertFalse(text.contains("[Admin]"), text)
+ }
+
+ @Test
+ fun `missing locale omits the language chip`() {
+ val text = plain(null, null)
+ assertFalse(text.contains("DE"), text)
+ assertTrue(text.contains("Steve"), text)
+ }
+}
diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/VanillaAdvancesTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/VanillaAdvancesTest.kt
new file mode 100644
index 0000000..cbdf5ba
--- /dev/null
+++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/VanillaAdvancesTest.kt
@@ -0,0 +1,16 @@
+package gg.grounds.proxy.velocity.tab
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class VanillaAdvancesTest {
+ @Test
+ fun `DE is two six-wide letters plus the gap already in each advance`() {
+ assertEquals(12, VanillaAdvances.width("DE"))
+ }
+
+ @Test
+ fun `ADMIN uses the narrow I`() {
+ assertEquals(6 + 6 + 6 + 4 + 6, VanillaAdvances.width("ADMIN"))
+ }
+}