From e2ef381e4e498c6d2d42747565b48c598d15ebcb Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 2 Sep 2026 18:17:23 +0200 Subject: [PATCH] feat: compact tab ranks and mark Bedrock players --- README.md | 15 +++ .../proxy/velocity/GroundsProxyPlugin.kt | 33 +++++- .../proxy/velocity/tab/BedrockPlayers.kt | 45 ++++++++ .../proxy/velocity/tab/BedrockRoster.kt | 106 ++++++++++++++++++ .../gg/grounds/proxy/velocity/tab/TabBadge.kt | 14 ++- .../grounds/proxy/velocity/tab/TabGlyphs.kt | 3 + .../proxy/velocity/tab/TabLabelAdvances.kt | 16 +++ .../gg/grounds/proxy/velocity/tab/TabList.kt | 5 +- .../gg/grounds/proxy/velocity/tab/TabName.kt | 36 +++--- .../proxy/velocity/tab/TabRankLabels.kt | 23 ++++ .../proxy/velocity/tab/BedrockPlayersTest.kt | 38 +++++++ .../proxy/velocity/tab/BedrockRosterTest.kt | 104 +++++++++++++++++ .../velocity/tab/TabLabelAdvancesTest.kt | 12 ++ .../grounds/proxy/velocity/tab/TabNameTest.kt | 60 ++++++++++ .../proxy/velocity/tab/TabRankLabelsTest.kt | 25 +++++ 15 files changed, 516 insertions(+), 19 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayers.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockRoster.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvances.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabels.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayersTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockRosterTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvancesTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabelsTest.kt diff --git a/README.md b/README.md index 197f0c2..2f07d13 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,25 @@ allowed can still _show_ the MOTD; `/motd set` then reports the refusal instead ## Configuration +The tab list uses compact rank labels (`ADMIN`, `DEV`, `MOD`, `USER`, `SUP`, `BUILD`) +and the resource pack's `grounds:tab_labels` font. Its five-pixel letters leave one +transparent pixel above and below the ink. Bedrock players get a bedrock-block +icon immediately before their name (`U+E004` in `grounds:tab`). Deploy the matching +resource pack before the proxy update. + +Floodgate identifies local Bedrock sessions, including linked Java accounts. Every +five seconds, proxies exchange edition snapshots on `proxy.platform.`; +remote snapshots expire after 30 seconds. All proxies in an environment must share +`GROUNDS_ENVIRONMENT`, have distinct `PROXY_ID` values, and be allowed to publish and +subscribe to that subject when NATS subject permissions are configured. Without a +snapshot, unlinked Floodgate UUIDs remain recognizable; linked accounts require +the originating proxy's snapshot. This cache only controls the visual indicator. + | env | meaning | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `NATS_URL` | broker for `proxy.system.*` / `proxy.transfer.*` (default `nats://nats.infra:4222`) | | `PROXY_ID` | this proxy's identity, recorded in a player's session — must differ per proxy (`velocity`, `velocity-2`) | +| `GROUNDS_ENVIRONMENT` | edition snapshot scope shared by all proxies in the same environment, e.g. `stage` | | `GROUNDS_TOKEN_FILE` | projected SA-token, presented as the NATS bearer and as the service-config gRPC bearer (default `/var/run/secrets/grounds/token`) | | `CONFIG_SERVICE_URL` | service-config contract target, e.g. `service-config:9000`. **Unset disables `/motd` entirely** and Velocity's own MOTD is served | | `CONFIG_GRPC_TARGET` | Legacy fallback for deployments that have not migrated to `CONFIG_SERVICE_URL` | 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 f8b8b61..d57707d 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -30,6 +30,8 @@ import gg.grounds.proxy.velocity.metrics.ProxySnapshot import gg.grounds.proxy.velocity.motd.MotdConfigStore import gg.grounds.proxy.velocity.motd.MotdGgClient import gg.grounds.proxy.velocity.motd.MotdManager +import gg.grounds.proxy.velocity.tab.BedrockPlayers +import gg.grounds.proxy.velocity.tab.BedrockRoster import gg.grounds.proxy.velocity.tab.TabList import io.nats.client.Subscription import java.net.InetSocketAddress @@ -114,6 +116,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { private var countSubscription: Subscription? = null private var metrics: ProxyMetrics? = null private var tabList: TabList? = null + private var bedrockRoster: BedrockRoster? = null /** * The network-wide MOTD, or null when this proxy has no service-config to read it from. Null @@ -170,6 +173,25 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { subscribeForProxy() + val bedrockPlayers = BedrockPlayers.of(logger) + val platformSubject = BedrockRoster.subject(System.getenv("GROUNDS_ENVIRONMENT")) + val roster = + BedrockRoster( + System.getenv("PROXY_ID").orEmpty(), + { proxy.allPlayers.map { it.uniqueId } }, + bedrockPlayers::isBedrock, + { payload -> platformSubject?.let { natsHandler.publish(it, payload) } }, + ) + bedrockRoster = roster + if (platformSubject != null) { + crossProxySubscriptions += natsHandler.subscribe(platformSubject, roster::receive) + } else { + logger.warn( + "GROUNDS_ENVIRONMENT is unset or invalid; linked Bedrock indicators are local-only" + ) + } + roster.refresh() + val messages = Translations.forBundle( "gg.grounds.proxy.messages", @@ -190,13 +212,20 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { roleQuery = { ProxyServiceRegistry.get(PlayerRoleQuery::class.java) }, localeQuery = { ProxyServiceRegistry.get(PlayerLocaleQuery::class.java) }, serverQuery = { ProxyServiceRegistry.get(ServerDisplayQuery::class.java) }, + isBedrock = roster::isBedrock, ) tabList = tab // On a timer as well as on join: the ping and the roster both change with no event to hang // off, and a footer that shows the ping from the moment you logged in is worse than none. proxy.scheduler - .buildTask(this, Runnable { tab.refreshAll() }) + .buildTask( + this, + Runnable { + roster.refresh() + tab.refreshAll() + }, + ) .delay(Duration.ofSeconds(TAB_REFRESH_SECONDS)) .repeat(Duration.ofSeconds(TAB_REFRESH_SECONDS)) .schedule() @@ -309,6 +338,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { @Subscribe fun onLogin(event: PostLoginEvent) { + bedrockRoster?.refresh() tabList?.refresh(event.player) } @@ -323,6 +353,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + bedrockRoster?.close() ProxyServiceRegistry.unregister(ProxyService::class.java) countSubscription?.let { natsHandler.unsubscribe(it) } crossProxySubscriptions.forEach { natsHandler.unsubscribe(it) } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayers.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayers.kt new file mode 100644 index 0000000..9d30397 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayers.kt @@ -0,0 +1,45 @@ +package gg.grounds.proxy.velocity.tab + +import java.util.UUID +import org.slf4j.Logger + +/** Optional Floodgate lookup; this plugin also runs on proxies without Floodgate. */ +internal class BedrockPlayers(private val logger: Logger, private val lookup: () -> Class<*>?) { + @Volatile private var api: Class<*>? = null + + fun isBedrock(id: UUID): Boolean { + val type = api ?: lookup()?.also { api = it } + if (type != null) { + try { + val instance = type.getMethod("getInstance").invoke(null) + if (instance != null) { + return type + .getMethod("isFloodgatePlayer", UUID::class.java) + .invoke(instance, id) == true + } + } catch (failure: ReflectiveOperationException) { + logger.debug("Could not read a player's edition from Floodgate", failure) + } + } + return isUnlinkedUuid(id) + } + + companion object { + // Floodgate represents an unlinked XUID in the UUID's lower 64 bits. + fun isUnlinkedUuid(id: UUID): Boolean = + id.mostSignificantBits == 0L && id.leastSignificantBits != 0L + + fun of(logger: Logger): BedrockPlayers = + BedrockPlayers(logger) { + try { + Class.forName( + "org.geysermc.floodgate.api.FloodgateApi", + false, + BedrockPlayers::class.java.classLoader, + ) + } catch (_: ClassNotFoundException) { + null + } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockRoster.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockRoster.kt new file mode 100644 index 0000000..be918a4 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/BedrockRoster.kt @@ -0,0 +1,106 @@ +package gg.grounds.proxy.velocity.tab + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Short-lived edition snapshots let Java proxies label linked Bedrock accounts too. */ +internal class BedrockRoster( + private val proxyId: String, + private val connectedPlayers: () -> Collection, + private val detect: (UUID) -> Boolean, + private val publish: (String) -> Unit, + private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }, +) { + private data class Snapshot(val players: Map, val receivedAt: Long) + + private val remote = ConcurrentHashMap() + @Volatile private var local: Map = emptyMap() + + fun refresh() { + local = connectedPlayers().associateWith(detect) + expire() + send(local) + } + + fun close() { + local = emptyMap() + send(emptyMap()) + remote.clear() + } + + fun isBedrock(id: UUID): Boolean { + local[id]?.let { + return it + } + val now = nowMillis() + val current = + remote.values + .filter { now - it.receivedAt < EXPIRY_MILLIS && id in it.players } + .maxByOrNull { it.receivedAt } + return current?.players?.get(id) ?: BedrockPlayers.isUnlinkedUuid(id) + } + + fun receive(raw: String) { + if (raw.length > MAX_PAYLOAD) return + val parsed = + try { + val root = JsonParser.parseString(raw) + if (!root.isJsonObject) return + val obj = root.asJsonObject + if (obj.get("schemaVersion")?.asInt != 1) return + val owner = + obj.get("proxy") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString ?: return + if (owner == proxyId || !owner.matches(PROXY_ID)) return + val entries = obj.get("players")?.takeIf { it.isJsonObject }?.asJsonObject ?: return + if (entries.size() > MAX_PLAYERS) return + val players = + entries.entrySet().associate { (key, value) -> + val id = UUID.fromString(key) + if ( + id.toString() != key || + !value.isJsonPrimitive || + !value.asJsonPrimitive.isBoolean + ) + return + id to value.asBoolean + } + owner to Snapshot(players, nowMillis()) + } catch (_: RuntimeException) { + return + } + expire() + if (!remote.containsKey(parsed.first) && remote.size >= MAX_PROXIES) return + remote[parsed.first] = parsed.second + } + + private fun send(players: Map) { + if (!proxyId.matches(PROXY_ID) || players.size > MAX_PLAYERS) return + val entries = JsonObject() + players.forEach { (id, bedrock) -> entries.addProperty(id.toString(), bedrock) } + val root = JsonObject() + root.addProperty("schemaVersion", 1) + root.addProperty("proxy", proxyId) + root.add("players", entries) + publish(root.toString()) + } + + private fun expire() { + val now = nowMillis() + remote.entries.removeIf { now - it.value.receivedAt >= EXPIRY_MILLIS } + } + + companion object { + const val EXPIRY_MILLIS = 30_000L + private const val MAX_PAYLOAD = 512 * 1024 + private const val MAX_PLAYERS = 10_000 + private const val MAX_PROXIES = 256 + private val PROXY_ID = Regex("[A-Za-z0-9_-]{1,128}") + + fun subject(environment: String?): String? = + environment?.trim()?.takeIf { it.matches(PROXY_ID) }?.let { "proxy.platform.$it" } + } +} 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 index 7e1cf71..79bfea5 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt @@ -3,11 +3,13 @@ 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.ShadowColor import net.kyori.adventure.text.format.TextColor +import net.kyori.adventure.text.format.TextDecoration object TabBadge { fun width(label: String): Int { - val textWidth = VanillaAdvances.width(label) + val textWidth = TabLabelAdvances.width(label) val pad = 4 val inner = max(textWidth + pad, TabGlyphs.LEFT_PX + TabGlyphs.RIGHT_PX + TabGlyphs.MIDDLE_PX) @@ -16,7 +18,7 @@ object TabBadge { } fun chip(label: String, fill: TextColor): Component { - val textWidth = VanillaAdvances.width(label) + val textWidth = TabLabelAdvances.width(label) val badgeWidth = width(label) val middles = badgeWidth - TabGlyphs.LEFT_PX - TabGlyphs.RIGHT_PX val padLeft = (badgeWidth - textWidth) / 2 @@ -36,7 +38,13 @@ object TabBadge { .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(label, NamedTextColor.WHITE) + .font(TabGlyphs.LABEL_FONT) + .decoration(TextDecoration.BOLD, false) + .decoration(TextDecoration.ITALIC, false) + .shadowColor(ShadowColor.none()) + ) .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 index 350913c..27efc68 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabGlyphs.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabGlyphs.kt @@ -4,10 +4,13 @@ import net.kyori.adventure.key.Key object TabGlyphs { val FONT: Key = Key.key("grounds", "tab") + val LABEL_FONT: Key = Key.key("grounds", "tab_labels") const val LOGO = '\uE000' const val BADGE_LEFT = '\uE001' const val BADGE_MIDDLE = '\uE002' const val BADGE_RIGHT = '\uE003' + const val BEDROCK_ICON = '\uE004' + const val BEDROCK_ADVANCE = 9 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/TabLabelAdvances.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvances.kt new file mode 100644 index 0000000..9de28fc --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvances.kt @@ -0,0 +1,16 @@ +package gg.grounds.proxy.velocity.tab + +object TabLabelAdvances { + fun width(text: String): Int = text.sumOf(::advance) + + private fun advance(ch: Char): Int = + when { + ch in 'A'..'Z' && ch != 'I' -> 6 + ch in '0'..'9' -> 6 + ch == 'I' -> 4 + ch == ' ' || ch == '-' -> 4 + ch == '!' || ch == '.' || ch == ':' -> 2 + ch == '+' || ch == '_' || ch == '?' || ch == '/' -> 6 + else -> VanillaAdvances.width(ch.toString()) + } +} 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 2c542e7..5aba44b 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 @@ -40,6 +40,7 @@ class TabList( private val roleQuery: () -> PlayerRoleQuery?, private val localeQuery: () -> PlayerLocaleQuery?, private val serverQuery: () -> ServerDisplayQuery?, + private val isBedrock: (java.util.UUID) -> Boolean = { false }, ) { /** Redraws everything [viewer] sees. */ @@ -109,7 +110,9 @@ class TabList( 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)) + entry.setDisplayName( + TabName.format(entry.profile.name, locale, role, isBedrock(entry.profile.id)) + ) } } 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 index a8446b8..4d126e3 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabName.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabName.kt @@ -4,13 +4,20 @@ 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.NamedTextColor import net.kyori.adventure.text.format.TextColor object TabName { private const val GAP = 2 - fun format(name: String, locale: Locale?, role: PlayerRole?): Component { + fun format( + name: String, + locale: Locale?, + role: PlayerRole?, + bedrock: Boolean = false, + ): Component { val colour = role?.colour?.let(TextColor::fromHexString) ?: Palette.TEXT + val rank = role?.let(TabRankLabels::resolve) val row = Component.text() locale ?.language @@ -19,31 +26,32 @@ object TabName { row.append(TabBadge.chip(language.uppercase(Locale.ROOT), Palette.TEXT_FAINT)) row.append(Component.text(TabSpaces.of(GAP)).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(GAP)).font(TabGlyphs.FONT)) - } + rank?.let { label -> + row.append(TabBadge.chip(label, colour)) + row.append(Component.text(TabSpaces.of(GAP)).font(TabGlyphs.FONT)) + } + if (bedrock) { + row.append( + Component.text(TabGlyphs.BEDROCK_ICON, NamedTextColor.WHITE).font(TabGlyphs.FONT) + ) + row.append(Component.text(TabSpaces.of(GAP)).font(TabGlyphs.FONT)) + } row.append(Component.text(name, colour)) - val pad = TabGlyphs.HEADER_WIDTH - rowWidth(name, locale, role) + val pad = TabGlyphs.HEADER_WIDTH - rowWidth(name, locale, rank, bedrock) if (pad > 0) { row.append(Component.text(TabSpaces.of(pad)).font(TabGlyphs.FONT)) } return row.build() } - private fun rowWidth(name: String, locale: Locale?, role: PlayerRole?): Int { + private fun rowWidth(name: String, locale: Locale?, rank: String?, bedrock: Boolean): Int { var width = VanillaAdvances.width(name) locale ?.language ?.takeIf { it.isNotBlank() } ?.let { width += TabBadge.width(it.uppercase(Locale.ROOT)) + GAP } - role - ?.name - ?.takeIf { it.isNotBlank() } - ?.let { width += TabBadge.width(it.uppercase(Locale.ROOT)) + GAP } + rank?.let { width += TabBadge.width(it) + GAP } + if (bedrock) width += TabGlyphs.BEDROCK_ADVANCE + GAP return width } } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabels.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabels.kt new file mode 100644 index 0000000..49799f9 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabels.kt @@ -0,0 +1,23 @@ +package gg.grounds.proxy.velocity.tab + +import gg.grounds.proxy.api.PlayerRole +import java.util.Locale + +object TabRankLabels { + fun resolve(role: PlayerRole): String? = + when (role.key.trim().lowercase(Locale.ROOT)) { + "admin", + "administrator" -> "ADMIN" + "dev", + "developer" -> "DEV" + "mod", + "moderator" -> "MOD" + "user", + "player", + "default" -> "USER" + "support", + "supporter" -> "SUP" + "builder" -> "BUILD" + else -> role.name.takeIf { it.isNotBlank() }?.uppercase(Locale.ROOT) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayersTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayersTest.kt new file mode 100644 index 0000000..2fb1416 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockPlayersTest.kt @@ -0,0 +1,38 @@ +package gg.grounds.proxy.velocity.tab + +import java.util.UUID +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory + +class BedrockPlayersTest { + private val logger = LoggerFactory.getLogger(javaClass) + private val linked = UUID.fromString("12345678-1234-4234-8234-123456789abc") + + object Api { + @JvmStatic fun getInstance(): Api = this + + fun isFloodgatePlayer(id: UUID): Boolean = id.toString().startsWith("12345678") + } + + @Test + fun `Floodgate recognizes linked accounts with ordinary Java UUIDs`() { + assertTrue(BedrockPlayers(logger) { Api::class.java }.isBedrock(linked)) + } + + @Test + fun `absent API can be discovered after plugin initialization`() { + var api: Class<*>? = null + val players = BedrockPlayers(logger) { api } + assertFalse(players.isBedrock(linked)) + api = Api::class.java + assertTrue(players.isBedrock(linked)) + } + + @Test + fun `missing or incompatible API degrades to unlinked UUID detection`() { + val id = UUID.fromString("00000000-0000-0000-0009-01f64d9a38a8") + assertTrue(BedrockPlayers(logger) { null }.isBedrock(id)) + assertFalse(BedrockPlayers(logger) { String::class.java }.isBedrock(linked)) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockRosterTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockRosterTest.kt new file mode 100644 index 0000000..ea57693 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/BedrockRosterTest.kt @@ -0,0 +1,104 @@ +package gg.grounds.proxy.velocity.tab + +import java.util.UUID +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class BedrockRosterTest { + private val linked = UUID.fromString("12345678-1234-4234-8234-123456789abc") + private val java = UUID.fromString("87654321-1234-4234-8234-123456789abc") + + @Test + fun `a Java proxy sees a linked Bedrock player on another proxy`() { + var payload = "" + val source = + BedrockRoster("bedrock", { listOf(linked, java) }, { it == linked }, { payload = it }) + source.refresh() + val viewer = BedrockRoster("java", { emptyList() }, { false }, {}) + viewer.receive(payload) + assertTrue(viewer.isBedrock(linked)) + assertFalse(viewer.isBedrock(java)) + } + + @Test + fun `replacement snapshot removes disconnected players`() { + var players = listOf(linked) + var payload = "" + val source = BedrockRoster("bedrock", { players }, { true }, { payload = it }) + val viewer = BedrockRoster("java", { emptyList() }, { false }, {}) + source.refresh() + viewer.receive(payload) + players = emptyList() + source.refresh() + viewer.receive(payload) + assertFalse(viewer.isBedrock(linked)) + } + + @Test + fun `expired remote snapshots do not mark linked Java accounts`() { + var now = 0L + var payload = "" + val source = BedrockRoster("bedrock", { listOf(linked) }, { true }, { payload = it }) + val viewer = BedrockRoster("java", { emptyList() }, { false }, {}, { now }) + source.refresh() + viewer.receive(payload) + now = BedrockRoster.EXPIRY_MILLIS + assertFalse(viewer.isBedrock(linked)) + } + + @Test + fun `local connection overrides an older remote Bedrock snapshot`() { + var payload = "" + val source = BedrockRoster("bedrock", { listOf(linked) }, { true }, { payload = it }) + source.refresh() + val viewer = BedrockRoster("java", { listOf(linked) }, { false }, {}) + viewer.receive(payload) + viewer.refresh() + assertFalse(viewer.isBedrock(linked)) + } + + @Test + fun `malformed snapshots are ignored and own echoes cannot replace local state`() { + val roster = BedrockRoster("java", { listOf(linked) }, { false }, {}) + roster.refresh() + listOf( + "not json", + "[]", + "{}", + "{\"schemaVersion\":1,\"proxy\":\"other\",\"players\":{\"invalid\":true}}", + "{\"schemaVersion\":1,\"proxy\":\"java\",\"players\":{\"$linked\":true}}", + ) + .forEach { roster.receive(it) } + assertFalse(roster.isBedrock(linked)) + } + + @Test + fun `unlinked Floodgate UUID is recognized before a snapshot arrives`() { + val roster = BedrockRoster("java", { emptyList() }, { false }, {}) + assertTrue(roster.isBedrock(UUID.fromString("00000000-0000-0000-0009-01f64d9a38a8"))) + assertFalse(roster.isBedrock(UUID(0, 0))) + assertFalse(roster.isBedrock(java)) + } + + @Test + fun `newer Java connection clears a remote linked Bedrock indication`() { + var now = 0L + var payload = "" + val viewer = BedrockRoster("viewer", { emptyList() }, { false }, {}, { now }) + BedrockRoster("bedrock", { listOf(linked) }, { true }, { payload = it }).refresh() + viewer.receive(payload) + now++ + BedrockRoster("java", { listOf(linked) }, { false }, { payload = it }).refresh() + viewer.receive(payload) + assertFalse(viewer.isBedrock(linked)) + } + + @Test + fun `platform broadcasts are scoped to a literal environment`() { + assertEquals("proxy.platform.stage", BedrockRoster.subject("stage")) + assertEquals("proxy.platform.prod", BedrockRoster.subject("prod")) + assertNull(BedrockRoster.subject(null)) + assertNull(BedrockRoster.subject("")) + assertNull(BedrockRoster.subject("stage.>")) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvancesTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvancesTest.kt new file mode 100644 index 0000000..4e7c92c --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabLabelAdvancesTest.kt @@ -0,0 +1,12 @@ +package gg.grounds.proxy.velocity.tab + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TabLabelAdvancesTest { + @Test + fun `label glyphs use their tab font advances`() { + assertEquals(28, TabLabelAdvances.width("ADMIN")) + assertEquals(44, TabLabelAdvances.width("I I!:-+_?/")) + } +} 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 index e5d51ca..bbe5403 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabNameTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabNameTest.kt @@ -2,6 +2,9 @@ package gg.grounds.proxy.velocity.tab import gg.grounds.proxy.api.PlayerRole import java.util.Locale +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.ShadowColor +import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -25,6 +28,51 @@ class TabNameTest { assertFalse(text.contains("[Admin]"), text) } + @Test + fun `stable rank keys render their short labels and pad using that label`() { + val text = + PlainTextComponentSerializer.plainText() + .serialize( + TabName.format( + "Steve", + Locale.GERMANY, + PlayerRole("developer", "Platform Engineer"), + ) + ) + + assertTrue(text.contains("DEV"), text) + assertFalse(text.contains("PLATFORM ENGINEER"), text) + assertTrue(text.contains(TabSpaces.of(91)), text) + } + + @Test + fun `bedrock icon sits directly before the player name`() { + val text = + PlainTextComponentSerializer.plainText() + .serialize(TabName.format("Steve", null, null, bedrock = true)) + + assertTrue(text.contains("${TabGlyphs.BEDROCK_ICON}${TabSpaces.of(2)}Steve"), text) + } + + @Test + fun `bedrock icon is omitted for java players`() { + val text = + PlainTextComponentSerializer.plainText().serialize(TabName.format("Steve", null, null)) + + assertFalse(text.contains(TabGlyphs.BEDROCK_ICON), text) + } + + @Test + fun `badge labels use the label font without inherited emphasis`() { + val component = TabName.format("Steve", null, PlayerRole("admin", "Owner")) + val label = findText(component, "ADMIN") + + assertEquals(TabGlyphs.LABEL_FONT, label.style().font()) + assertEquals(TextDecoration.State.FALSE, label.style().decoration(TextDecoration.BOLD)) + assertEquals(TextDecoration.State.FALSE, label.style().decoration(TextDecoration.ITALIC)) + assertEquals(ShadowColor.none(), label.style().shadowColor()) + } + @Test fun `missing locale omits the language chip`() { val text = plain(null, null) @@ -49,4 +97,16 @@ class TabNameTest { PlainTextComponentSerializer.plainText().serialize(TabName.format(name, null, null)) assertEquals(name, text) } + + private fun findText( + component: net.kyori.adventure.text.Component, + expected: String, + ): TextComponent { + if (component is TextComponent && component.content() == expected) return component + return component + .children() + .asSequence() + .mapNotNull { child -> runCatching { findText(child, expected) }.getOrNull() } + .firstOrNull() ?: error("Could not find $expected") + } } diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabelsTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabelsTest.kt new file mode 100644 index 0000000..7463297 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabRankLabelsTest.kt @@ -0,0 +1,25 @@ +package gg.grounds.proxy.velocity.tab + +import gg.grounds.proxy.api.PlayerRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TabRankLabelsTest { + @Test + fun `stable role keys use compact tab labels`() { + assertEquals("ADMIN", TabRankLabels.resolve(PlayerRole("administrator", "Owner"))) + assertEquals("DEV", TabRankLabels.resolve(PlayerRole("developer", "Platform Engineer"))) + assertEquals("MOD", TabRankLabels.resolve(PlayerRole("moderator", "Community Guardian"))) + assertEquals("USER", TabRankLabels.resolve(PlayerRole("player", "Member"))) + assertEquals("SUP", TabRankLabels.resolve(PlayerRole("supporter", "Helper"))) + assertEquals("BUILD", TabRankLabels.resolve(PlayerRole("builder", "Architect"))) + } + + @Test + fun `unknown roles retain an uppercase readable name`() { + assertEquals( + "COMMUNITY LEAD", + TabRankLabels.resolve(PlayerRole("community-lead", "Community Lead")), + ) + } +}