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")) + } +}