From 75b3a3f683e3eda75f0d4d37e4a72fc59ae8ca39 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 16:36:21 +0100 Subject: [PATCH 01/13] feat: welcome players with community links and voice setup --- README.md | 29 ++ .../plans/2026-11-04-join-welcome.md | 39 +++ .../specs/2026-11-04-join-welcome-design.md | 11 + .../proxy/velocity/GroundsProxyPlugin.kt | 28 ++ .../proxy/velocity/welcome/WelcomeListener.kt | 159 +++++++++ .../proxy/velocity/welcome/WelcomeMessage.kt | 88 +++++ .../gg/grounds/proxy/messages.properties | 3 + .../gg/grounds/proxy/messages_de.properties | 4 + .../gg/grounds/proxy/messages_es.properties | 4 + .../gg/grounds/proxy/messages_fr.properties | 4 + .../velocity/welcome/WelcomeListenerTest.kt | 330 ++++++++++++++++++ .../velocity/welcome/WelcomeMessageTest.kt | 90 +++++ 12 files changed, 789 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-04-join-welcome.md create mode 100644 docs/superpowers/specs/2026-11-04-join-welcome-design.md create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt diff --git a/README.md b/README.md index f90c6a3..1d8a713 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,35 @@ A Velocity proxy only knows its own players. Ask it for `dahendriik` while they Local first, always — a player on this proxy is already in memory and costs nothing to find. +## Join welcome + +A fresh network join receives a single welcome block with the Grounds chat logo, +the player's name, and independent Discord, TikTok and Shop links. Delivery waits +five seconds after the first backend connection so client mod negotiation and +resource-pack loading can finish. Clients without a verified chat font see the +Grounds name instead of an unsupported glyph. + +Discord defaults to `https://grnds.link/discord`, matching the lobby bossbar. +TikTok (`https://example.com/grounds/tiktok`) and Shop +(`https://example.com/grounds/shop`) are intentional placeholders. Replace these +with `WELCOME_TIKTOK_URL` and `WELCOME_SHOP_URL`; `WELCOME_DISCORD_URL` can override +Discord as well. Keep these values consistent across proxy deployments. + +Clients that have not announced Simple Voice Chat receive a short description +and a link to the official installation guide. Client channel registration or a +voice handshake suppresses that section even if voice is disabled or UDP cannot +connect. Handshake messages continue to the backend unchanged. Server switches +do not repeat the welcome. A connection-scoped `grounds:welcome` client cookie +carries whether the block was delivered across proxy/region transfers: an early +redirect still gets its first welcome at the destination. Fresh logins reset +the marker before preferred-region routing. Disconnect and shutdown cancel +pending delivery. + +Before rollout, check a fresh join on each proxy, a backend switch, and a proxy +transfer. Check clients with the mod absent, present but disabled, and present +with UDP blocked; only the first should get installation help. Also check a +declined resource pack and a disconnect/reconnect during the five-second delay. + ## How the pieces fit ``` diff --git a/docs/superpowers/plans/2026-11-04-join-welcome.md b/docs/superpowers/plans/2026-11-04-join-welcome.md new file mode 100644 index 0000000..954c0f0 --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-join-welcome.md @@ -0,0 +1,39 @@ +# Join Welcome Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Deliver the agreed welcome block once per fresh network join with conditional voice installation help. +**Architecture:** A renderer builds translated Adventure components; a Velocity listener handles connection lifecycle and observes mod negotiation. Wire both into plugin-proxy. +**Tech Stack:** Kotlin, Velocity, Adventure, library-i18n, JUnit 6. +**Spec:** `docs/superpowers/specs/2026-11-04-join-welcome-design.md` + +## Global Constraints + +- Discord is `https://grnds.link/discord`; TikTok and Shop are replaceable placeholders. +- Skip server switches and delivered cross-proxy transfers; preserve a pending first welcome through early region redirects. Clean up on disconnect/shutdown. +- Voice off or disconnected does not mean the mod is absent. +- Draw logo only with verified chat-font readiness; support all four plugin locales. + +## Review Focus + +- Mod handshake before the first backend connection must suppress the hint. +- Forward client voice handshakes intact and ignore backend-originated messages. +- Late callbacks from a disconnected session must not message a reconnect. +- A transfer onto another proxy must preserve the first welcome if still pending and suppress an already delivered welcome. +- Missing packs and markup-like player names must produce readable, safe text. + +### Task 1: Welcome rendering and lifecycle + +**Files:** New `welcome/WelcomeMessage.kt`, `welcome/WelcomeListener.kt`, tests under the same package; modify plugin initialization, four resource bundles, README. +**Interfaces:** Renderer `render(playerName, locale, logoReady, voiceInstalled): Component`; listener accepts a delayed scheduler and readiness lookup to test actual event handlers without a running proxy. + +- [x] Write rendering tests for clickable URLs, localized voice visibility, safe names and logo fallback; lifecycle tests for switches, transfers, early/disabled voice, backend spoofing, disconnect and reconnect. +- [x] Run `./gradlew :velocity:test --offline --console=plain` and confirm new tests fail because the feature is absent. +- [x] Implement renderer, observer and lifecycle gates; wire a five-second delayed task and register observed voice channels. Add translations and document configurable placeholder links. +- [x] Run `./gradlew test --offline --console=plain` and repository formatting checks. Review the diff for forwarding, cleanup and cross-proxy behavior. + +Execution: primary agent, current suitable checkout on a task branch; no separate implementation agents needed. + +Review ruling: use a client cookie rather than unconditional transfer suppression, because preferred-region redirects happen at PostLogin before a first backend connection. Cookie state is cosmetic, reset for each LOGIN, queried only for TRANSFER, and tracked by the exact connection. + +Validation: `./gradlew spotlessApply test spotlessCheck --offline --console=plain` passed after the transfer fix; 229 tests, zero failures/errors/skips. `git diff --check` passed. Live Minecraft clients and multi-proxy deployment smoke checks were not available in this session. diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md new file mode 100644 index 0000000..82f9fe0 --- /dev/null +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -0,0 +1,11 @@ +# Join welcome + +Show a compact Grounds welcome once on a fresh network connection. Reuse the existing chat logo when the client's chat resource pack is verified; otherwise show the Grounds name. Put clickable Discord, TikTok and Shop links below it. Discord uses the lobby bossbar's `https://grnds.link/discord`; the other two links use explicitly documented, replaceable placeholders. + +Keep the original voice request: explain party, game and nearby voice and link to Simple Voice Chat installation only when the client has not announced the mod. Disabled voice and failed UDP connections must not be treated as missing installation. Observe client channel registration and the voice handshake without consuming its messages. + +Approach: the existing proxy plugin owns network joins and translations, so render and deliver there. Backend delivery would repeat on server changes; voice presence snapshots conflate missing installation with disconnected voice. Wait five seconds after the first backend connection for mod negotiation and pack readiness. Carry a delivered marker in the namespaced `grounds:welcome` client cookie across transfers. Reset it on a fresh login before preferred-region routing; incoming transfers with an absent/pending marker receive their first welcome, while delivered markers suppress repeats. Cancel delayed delivery on disconnect, and identify pending work by connection object to avoid stale reconnect callbacks. Provide English, German, French and Spanish copy. + +User supplied the layout and selected the three links and placeholder policy. Implementation choices: five-second grace period, standard text fallback, configurable link environment variables. No publishing is part of this change. + +Review fix: an unconditional transfer skip loses the first welcome when PreferredRegionListener redirects before backend selection. Regression tests cover both this route and transfers during the delivery delay, delivered markers, and stale cookie replies. 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 d210a5d..230bd99 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -20,6 +20,7 @@ import gg.grounds.BuildInfo import gg.grounds.i18n.Translations import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.NetworkPlayerCounts +import gg.grounds.proxy.api.PlayerChatAppearanceQuery import gg.grounds.proxy.api.PlayerDisplayQuery import gg.grounds.proxy.api.PlayerMessageRenderers import gg.grounds.proxy.api.PlayerRoleQuery @@ -50,6 +51,9 @@ import gg.grounds.proxy.velocity.tab.PlayerDisplayQueryImpl import gg.grounds.proxy.velocity.tab.TabList import gg.grounds.proxy.velocity.tab.TabParties import gg.grounds.proxy.velocity.tab.VoicePresences +import gg.grounds.proxy.velocity.welcome.WelcomeLinks +import gg.grounds.proxy.velocity.welcome.WelcomeListener +import gg.grounds.proxy.velocity.welcome.WelcomeMessage import io.micrometer.core.instrument.MeterRegistry import io.nats.client.Subscription import java.time.Duration @@ -216,6 +220,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 welcome: WelcomeListener? = null private var bedrockRoster: BedrockRoster? = null private val tabParties = TabParties() private val voicePresences = VoicePresences() @@ -290,6 +295,28 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { proxy.channelRegistrar.register(lobbyIdentifier) proxy.eventManager.register(this, LobbyCountsListener(lobbyIdentifier, service, logger)) + val welcomeListener = + WelcomeListener( + messages, + WelcomeMessage(messages, WelcomeLinks.from()), + schedule = { task -> + proxy.scheduler + .buildTask(this, task) + .delay(WelcomeListener.DELAY_SECONDS, TimeUnit.SECONDS) + .schedule() + }, + logoReady = { player -> + ProxyServiceRegistry.get(PlayerChatAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.chatFontReady == true + }, + ) + welcome = welcomeListener + proxy.channelRegistrar.register( + MinecraftChannelIdentifier.from(WelcomeListener.VOICE_CHANNEL) + ) + proxy.eventManager.register(this, welcomeListener) + // The network-wide player count, pushed every few seconds. Subscribed once for the whole // proxy rather than per player: it is a property of the network, not of anybody's session. countSubscription = @@ -609,6 +636,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + welcome?.close() bedrockRoster?.close() ProxyServiceRegistry.unregister(ProxyService::class.java) ProxyServiceRegistry.unregister(PlayerDisplayQuery::class.java) diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt new file mode 100644 index 0000000..80b2609 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt @@ -0,0 +1,159 @@ +package gg.grounds.proxy.velocity.welcome + +import com.velocitypowered.api.event.PostOrder +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PluginMessageEvent +import com.velocitypowered.api.event.connection.PostLoginEvent +import com.velocitypowered.api.event.player.CookieReceiveEvent +import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent +import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.network.HandshakeIntent +import com.velocitypowered.api.network.ProtocolVersion +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.i18n.Translations +import java.util.concurrent.ConcurrentHashMap +import net.kyori.adventure.key.Key + +/** + * Observe installation, not UDP presence: a muted or disconnected mod still counts as installed. + */ +internal class WelcomeListener( + private val messages: Translations, + private val renderer: WelcomeMessage, + private val schedule: (Runnable) -> ScheduledTask, + private val logoReady: (Player) -> Boolean, +) : AutoCloseable { + // Connection objects prevent a delayed task or old disconnect from affecting a reconnect. + private val sessions = ConcurrentHashMap() + @Volatile private var closed = false + + private class Join(transfer: Boolean) { + var voiceInstalled = false + var welcomed: Boolean? = if (transfer) null else false + var cookieRequested = false + var connected = false + var scheduled = false + var sent = false + var task: ScheduledTask? = null + } + + private fun session(player: Player): Join = + sessions.computeIfAbsent(player) { + Join(player.handshakeIntent == HandshakeIntent.TRANSFER) + } + + @Subscribe(order = PostOrder.FIRST) + fun onLogin(event: PostLoginEvent) { + val player = event.player + if (closed || player.protocolVersion < ProtocolVersion.MINECRAFT_1_20_5) return + val join = session(player) + synchronized(join) { + if (closed || sessions[player] !== join) return + if (player.handshakeIntent == HandshakeIntent.TRANSFER) { + join.cookieRequested = true + player.requestCookie(COOKIE_KEY) + } else { + // Reset before PreferredRegionListener can transfer this fresh network join. + player.storeCookie(COOKIE_KEY, byteArrayOf(0)) + } + } + } + + @Subscribe + fun onCookie(event: CookieReceiveEvent) { + if (event.originalKey != COOKIE_KEY) return + val player = event.player + val join = sessions[player] ?: return + synchronized(join) { + if (closed || sessions[player] !== join || !join.cookieRequested) return + event.result = CookieReceiveEvent.ForwardResult.handled() + join.cookieRequested = false + join.welcomed = event.originalData?.contentEquals(byteArrayOf(1)) == true + scheduleIfNeeded(player, join) + } + } + + @Subscribe + fun onChannels(event: PlayerChannelRegisterEvent) { + if (event.channels.any { it.id.startsWith("voicechat:") }) markInstalled(event.player) + } + + @Subscribe(order = PostOrder.LAST) + fun onPluginMessage(event: PluginMessageEvent) { + if (event.identifier.id != VOICE_CHANNEL || event.data.size != 4) return + val player = event.source as? Player ?: return + markInstalled(player) + // Leave the result unchanged: the backend must receive the original handshake. + } + + private fun markInstalled(player: Player) { + if (closed) return + val join = session(player) + synchronized(join) { join.voiceInstalled = true } + } + + @Subscribe + fun onServerConnected(event: ServerPostConnectEvent) { + val player = event.player + if (closed || event.previousServer != null) return + val join = session(player) + synchronized(join) { + join.connected = true + scheduleIfNeeded(player, join) + } + } + + /** Called under the connection's monitor, after either backend connect or cookie reply. */ + private fun scheduleIfNeeded(player: Player, join: Join) { + if ( + closed || + sessions[player] !== join || + !join.connected || + join.welcomed != false || + join.scheduled + ) + return + join.scheduled = true + join.task = schedule(Runnable { deliver(player, join) }) + } + + private fun deliver(player: Player, join: Join) { + synchronized(join) { + if (closed || sessions[player] !== join || join.sent || !player.isActive) return + join.sent = true + join.task = null + player.sendMessage( + renderer.render( + player.username, + messages.localeOf(player), + logoReady(player), + join.voiceInstalled, + ) + ) + join.welcomed = true + if (player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5) { + player.storeCookie(COOKIE_KEY, byteArrayOf(1)) + } + } + } + + @Subscribe + fun onDisconnect(event: DisconnectEvent) { + val join = sessions.remove(event.player) ?: return + synchronized(join) { join.task?.cancel() } + } + + override fun close() { + closed = true + sessions.values.forEach { join -> synchronized(join) { join.task?.cancel() } } + sessions.clear() + } + + companion object { + private val COOKIE_KEY = Key.key("grounds:welcome") + const val VOICE_CHANNEL = "voicechat:request_secret" + const val DELAY_SECONDS = 5L + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt new file mode 100644 index 0000000..f838e0f --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -0,0 +1,88 @@ +package gg.grounds.proxy.velocity.welcome + +import gg.grounds.i18n.MessageKey +import gg.grounds.i18n.Palette +import gg.grounds.i18n.Translations +import java.util.Locale +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.format.TextColor +import net.kyori.adventure.text.format.TextDecoration + +internal data class WelcomeLinks( + val discord: String = "https://grnds.link/discord", + val tiktok: String = "https://example.com/grounds/tiktok", + val shop: String = "https://example.com/grounds/shop", +) { + companion object { + fun from(environment: (String) -> String? = System::getenv): WelcomeLinks { + val defaults = WelcomeLinks() + return WelcomeLinks( + environment("WELCOME_DISCORD_URL")?.takeIf(String::isNotBlank) ?: defaults.discord, + environment("WELCOME_TIKTOK_URL")?.takeIf(String::isNotBlank) ?: defaults.tiktok, + environment("WELCOME_SHOP_URL")?.takeIf(String::isNotBlank) ?: defaults.shop, + ) + } + } +} + +/** One branded block: every link owns its click event, so it cannot leak onto the next line. */ +internal class WelcomeMessage( + private val messages: Translations, + private val links: WelcomeLinks = WelcomeLinks(), +) { + private enum class Copy(override val id: String) : MessageKey { + GREETING("welcome.greeting"), + VOICE("welcome.voice"), + INSTALL("welcome.install"), + } + + fun render( + playerName: String, + locale: Locale, + logoReady: Boolean, + voiceInstalled: Boolean, + ): Component { + val brand = Component.text("GROUNDS", Palette.TEXT, TextDecoration.BOLD) + val result = Component.text().appendNewline() + if (logoReady) { + result + .append( + Component.text("\uE100") + .font(Key.key("grounds:chat")) + .color(TextColor.color(0xFFFFFF)) + ) + .append(Component.space()) + } + result + .append(brand) + .appendNewline() + .append(messages.render(Copy.GREETING, locale, "player" to playerName)) + .appendNewline() + .appendNewline() + .append(link(Component.text("Discord", Palette.ACCENT), links.discord)) + .append(Component.text(" · ", Palette.TEXT_FAINT)) + .append(link(Component.text("TikTok", Palette.ACCENT), links.tiktok)) + .append(Component.text(" · ", Palette.TEXT_FAINT)) + .append(link(Component.text("Shop", Palette.ACCENT), links.shop)) + if (!voiceInstalled) { + result + .appendNewline() + .appendNewline() + .append(messages.render(Copy.VOICE, locale)) + .appendNewline() + .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)) + } + return result.appendNewline().build() + } + + private fun link(label: Component, url: String): Component = + label + .clickEvent(ClickEvent.openUrl(url)) + .hoverEvent(Component.text(url, Palette.TEXT_MUTED)) + + companion object { + const val INSTALL_URL = "https://modrepo.de/minecraft/voicechat/wiki/installation" + } +} diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index 21623c5..ba534b3 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -78,3 +78,6 @@ proxy.current=You are on . proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. +welcome.greeting=Welcome to Grounds, ! +welcome.voice=Voice chat for parties, games\nand players nearby. +welcome.install=→ Click here to install the voice chat mod. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 41a8fdc..0e8bcdc 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -76,3 +76,7 @@ proxy.current=Du bist auf . proxy.entry=Zu wechseln (): proxy.entry.current= (): (du bist hier) proxy.usage=Klicke auf einen Proxy, um zu wechseln. +# Welcome block +welcome.greeting=Willkommen auf Grounds, ! +welcome.voice=Voice Chat für Partys, Spielrunden\nund Spieler in deiner Nähe. +welcome.install=→ Klicke hier, um die Voice-Chat-Mod zu installieren. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 5ca0fc6..0fa8e36 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -76,3 +76,7 @@ proxy.current=Estás en . proxy.entry=Cambiar a (): proxy.entry.current= (): (estás aquí) proxy.usage=Haz clic en un proxy para cambiar. +# Welcome block +welcome.greeting=¡Bienvenido a Grounds, ! +welcome.voice=Chat de voz para grupos, partidas\ny jugadores cercanos. +welcome.install=→ Haz clic aquí para instalar el mod de chat de voz. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index e9d8b70..2d6e450 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -76,3 +76,7 @@ proxy.current=Vous êtes sur . proxy.entry=Aller vers () : proxy.entry.current= () : (vous êtes ici) proxy.usage=Cliquez sur un proxy pour changer. +# Welcome block +welcome.greeting=Bienvenue sur Grounds, ! +welcome.voice=Le chat vocal pour les groupes, les parties\net les joueurs à proximité. +welcome.install=→ Clique ici pour installer le mod de chat vocal. diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt new file mode 100644 index 0000000..6235ad1 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt @@ -0,0 +1,330 @@ +package gg.grounds.proxy.velocity.welcome + +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PluginMessageEvent +import com.velocitypowered.api.event.connection.PostLoginEvent +import com.velocitypowered.api.event.player.CookieReceiveEvent +import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent +import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.network.HandshakeIntent +import com.velocitypowered.api.network.ProtocolVersion +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.proxy.server.RegisteredServer +import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.i18n.Translations +import java.lang.reflect.Proxy +import java.util.Locale +import java.util.Optional +import java.util.UUID +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WelcomeListenerTest { + private val pending = mutableListOf() + private var cancelled = 0 + private var ready = false + private val messages = + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader) + private val listener = newListener() + + private fun newListener() = + WelcomeListener( + messages, + WelcomeMessage(messages), + schedule = { work -> + pending += work + fake(ScheduledTask::class.java) { name, _ -> + when (name) { + "cancel" -> { + cancelled++ + null + } + else -> null + } + } + }, + logoReady = { ready }, + ) + + private class Client( + val player: Player, + val received: MutableList, + val cookies: MutableMap, + val requested: MutableList, + ) + + private fun client( + intent: HandshakeIntent = HandshakeIntent.LOGIN, + id: UUID = UUID.randomUUID(), + cookies: MutableMap = mutableMapOf(), + ): Client { + val received = mutableListOf() + val requested = mutableListOf() + val player = + fake(Player::class.java) { name, args -> + when (name) { + "getUniqueId" -> id + "getUsername" -> "Alex" + "getHandshakeIntent" -> intent + "getProtocolVersion" -> ProtocolVersion.MINECRAFT_1_21 + "storeCookie" -> { + cookies[args!![0] as Key] = (args[1] as ByteArray).copyOf() + null + } + "requestCookie" -> { + requested += args!![0] as Key + null + } + "isActive" -> true + "get" -> Optional.of(Locale.ENGLISH) + "sendMessage" -> { + received += args!![0] as Component + null + } + else -> null + } + } + return Client(player, received, cookies, requested) + } + + private fun plain(client: Client) = + client.received.joinToString { PlainTextComponentSerializer.plainText().serialize(it) } + + private fun connect(client: Client) = + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + + @Test + fun `backend reconnect events cannot schedule a second welcome`() { + val client = client() + connect(client) + connect(client) + assertEquals(1, pending.size) + pending.single().run() + connect(client) + assertEquals(1, client.received.size) + assertEquals(1, pending.size) + assertTrue(plain(client).contains("install")) + } + + private val cookieKey = Key.key("grounds:welcome") + + @Test + fun `transfers with delivered cookie onto either proxy do not repeat the welcome`() { + for (proxy in listOf(listener, newListener())) { + val client = client(HandshakeIntent.TRANSFER) + proxy.onLogin(PostLoginEvent(client.player)) + proxy.onServerConnected(ServerPostConnectEvent(client.player, null)) + assertEquals(listOf(cookieKey), client.requested) + assertTrue(pending.isEmpty()) + proxy.onCookie(CookieReceiveEvent(client.player, cookieKey, byteArrayOf(1))) + assertTrue(pending.isEmpty()) + } + } + + @Test + fun `preferred region redirect before any backend receives its first welcome at destination`() { + val cookies = mutableMapOf(cookieKey to byteArrayOf(1)) + val source = client(cookies = cookies) + listener.onLogin(PostLoginEvent(source.player)) + listener.onDisconnect( + DisconnectEvent(source.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val destination = client(HandshakeIntent.TRANSFER, cookies = cookies) + val otherProxy = newListener() + otherProxy.onLogin(PostLoginEvent(destination.player)) + otherProxy.onCookie(CookieReceiveEvent(destination.player, cookieKey, cookies[cookieKey])) + otherProxy.onChannels( + PlayerChannelRegisterEvent( + destination.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + otherProxy.onServerConnected(ServerPostConnectEvent(destination.player, null)) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, destination.received.size) + assertFalse(plain(destination).contains("install")) + assertArrayEquals(byteArrayOf(1), cookies[cookieKey]) + } + + @Test + fun `transfer during the delivery delay moves the pending welcome to the destination`() { + val cookies = mutableMapOf() + val source = client(cookies = cookies) + listener.onLogin(PostLoginEvent(source.player)) + connect(source) + listener.onDisconnect( + DisconnectEvent(source.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val destination = client(HandshakeIntent.TRANSFER, cookies = cookies) + val otherProxy = newListener() + otherProxy.onLogin(PostLoginEvent(destination.player)) + otherProxy.onServerConnected(ServerPostConnectEvent(destination.player, null)) + otherProxy.onCookie(CookieReceiveEvent(destination.player, cookieKey, cookies[cookieKey])) + assertEquals(2, pending.size) + pending.forEach(Runnable::run) + assertTrue(source.received.isEmpty()) + assertEquals(1, destination.received.size) + } + + @Test + fun `absent transfer cookie allows welcome but stale responses cannot reach a reconnect`() { + val id = UUID.randomUUID() + val old = client(HandshakeIntent.TRANSFER, id) + listener.onLogin(PostLoginEvent(old.player)) + listener.onDisconnect( + DisconnectEvent(old.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val current = client(HandshakeIntent.TRANSFER, id) + listener.onLogin(PostLoginEvent(current.player)) + connect(current) + listener.onCookie(CookieReceiveEvent(old.player, cookieKey, null)) + assertTrue(pending.isEmpty()) + val response = CookieReceiveEvent(current.player, cookieKey, null) + listener.onCookie(response) + assertFalse(response.result.isAllowed) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, current.received.size) + } + + @Test + fun `a backend switch never initiates a welcome`() { + val client = client() + val previous = fake(RegisteredServer::class.java) { _, _ -> null } + listener.onServerConnected(ServerPostConnectEvent(client.player, previous)) + assertTrue(pending.isEmpty()) + } + + @Test + fun `client mod registration before backend connection hides installation help`() { + val client = client() + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + connect(client) + pending.single().run() + assertEquals(1, client.received.size) + assertFalse(plain(client).contains("install")) + } + + @Test + fun `client handshake hides help even without connected voice and is forwarded intact`() { + val client = client() + connect(client) + val backend = fake(ServerConnection::class.java) { _, _ -> null } + val event = + PluginMessageEvent( + client.player, + backend, + MinecraftChannelIdentifier.from("voicechat:request_secret"), + byteArrayOf(0, 0, 0, 20), + ) + listener.onPluginMessage(event) + assertTrue(event.result.isAllowed) + pending.single().run() + assertFalse(plain(client).contains("install")) + } + + @Test + fun `backend messages do not claim the client installed a mod`() { + val client = client() + connect(client) + val backend = fake(ServerConnection::class.java) { _, _ -> null } + listener.onPluginMessage( + PluginMessageEvent( + backend, + client.player, + MinecraftChannelIdentifier.from("voicechat:request_secret"), + byteArrayOf(0, 0, 0, 20), + ) + ) + pending.single().run() + assertTrue(plain(client).contains("install")) + } + + @Test + fun `unrelated channels and malformed voice packets cannot suppress help`() { + val client = client() + connect(client) + val backend = fake(ServerConnection::class.java) { _, _ -> null } + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("other:secret")), + ) + ) + listener.onPluginMessage( + PluginMessageEvent( + client.player, + backend, + MinecraftChannelIdentifier.from("voicechat:request_secret"), + byteArrayOf(), + ) + ) + listener.onPluginMessage( + PluginMessageEvent( + client.player, + backend, + MinecraftChannelIdentifier.from("other:secret"), + byteArrayOf(0, 0, 0, 20), + ) + ) + pending.single().run() + pending.single().run() + assertEquals(1, client.received.size) + assertTrue(plain(client).contains("install")) + } + + @Test + fun `disconnect cancels delivery and a stale callback cannot reach a new connection`() { + val id = UUID.randomUUID() + val first = client(id = id) + connect(first) + listener.onDisconnect( + DisconnectEvent(first.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val second = client(id = id) + connect(second) + pending[0].run() + pending[1].run() + assertEquals(1, cancelled) + assertTrue(first.received.isEmpty()) + assertEquals(1, second.received.size) + } + + @Test + fun `pack readiness is read when sending and shutdown cancels outstanding work`() { + val first = client() + connect(first) + ready = true + pending[0].run() + assertTrue(plain(first).contains('\uE100')) + val second = client() + connect(second) + listener.close() + pending[1].run() + assertTrue(second.received.isEmpty()) + assertTrue(cancelled >= 1) + } + + private fun fake(type: Class, invoke: (String, Array?) -> Any?): T = + type.cast( + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(type)) { proxy, method, args -> + when (method.name) { + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.get(0) + "toString" -> type.simpleName + else -> invoke(method.name, args) + } + } + ) +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt new file mode 100644 index 0000000..c0419f9 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -0,0 +1,90 @@ +package gg.grounds.proxy.velocity.welcome + +import gg.grounds.i18n.Translations +import java.util.Locale +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WelcomeMessageTest { + private val renderer = + WelcomeMessage(Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader)) + + private fun nodes(component: Component): List = + listOf(component) + component.children().flatMap(::nodes) + + private fun url(component: Component) = + (component.clickEvent()?.payload() as? ClickEvent.Payload.Text)?.value() + + private fun plain(component: Component) = + PlainTextComponentSerializer.plainText().serialize(component) + + @Test + fun `links below the brand have independent click targets`() { + val message = renderer.render("Alex", Locale.ENGLISH, false, true) + assertTrue(plain(message).indexOf("Grounds") < plain(message).indexOf("Discord")) + assertEquals( + listOf( + "https://grnds.link/discord", + "https://example.com/grounds/tiktok", + "https://example.com/grounds/shop", + ), + nodes(message).mapNotNull { url(it) }, + ) + } + + @Test + fun `installation link only appears for clients without the mod in every locale`() { + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + val missing = renderer.render("Alex", locale, false, false) + val installed = renderer.render("Alex", locale, false, true) + assertTrue( + nodes(missing).any { + url(it) == "https://modrepo.de/minecraft/voicechat/wiki/installation" + } + ) + assertFalse( + nodes(installed).any { + url(it) == "https://modrepo.de/minecraft/voicechat/wiki/installation" + } + ) + assertFalse(plain(missing).contains("welcome."), plain(missing)) + } + assertTrue( + plain(renderer.render("Alex", Locale.GERMAN, false, false)).contains("Willkommen") + ) + } + + @Test + fun `logo requires verified pack and player names cannot inject markup`() { + val fallback = renderer.render("Alex", Locale.ENGLISH, false, true) + val packed = renderer.render("Alex", Locale.ENGLISH, true, true) + assertTrue(plain(fallback).contains("Alex")) + assertFalse(plain(fallback).contains('\uE100')) + assertTrue( + nodes(packed).any { + it.font() == Key.key("grounds:chat") && plain(it).contains('\uE100') + } + ) + } + + @Test + fun `configured links replace the placeholders`() { + val configured = + WelcomeMessage( + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), + WelcomeLinks( + tiktok = "https://www.tiktok.com/@test", + shop = "https://shop.example.org", + ), + ) + .render("Alex", Locale.ENGLISH, false, true) + val urls = nodes(configured).mapNotNull { url(it) } + assertTrue("https://www.tiktok.com/@test" in urls) + assertTrue("https://shop.example.org" in urls) + } +} From f9e019dff784043fcb0eb5e967254b224de6f184 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 19:26:41 +0100 Subject: [PATCH 02/13] fix: use large wordmark and icon rows in join welcome --- README.md | 8 ++-- .../plans/2026-11-04-join-welcome.md | 2 + .../specs/2026-11-04-join-welcome-design.md | 6 ++- .../proxy/velocity/GroundsProxyPlugin.kt | 5 +-- .../proxy/velocity/welcome/WelcomeMessage.kt | 40 +++++++++---------- .../gg/grounds/proxy/messages.properties | 2 +- .../gg/grounds/proxy/messages_de.properties | 2 +- .../gg/grounds/proxy/messages_es.properties | 2 +- .../gg/grounds/proxy/messages_fr.properties | 2 +- .../velocity/welcome/WelcomeListenerTest.kt | 2 +- .../velocity/welcome/WelcomeMessageTest.kt | 13 +++++- 11 files changed, 50 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1d8a713..d72af94 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,12 @@ Local first, always — a player on this proxy is already in memory and costs no ## Join welcome -A fresh network join receives a single welcome block with the Grounds chat logo, -the player's name, and independent Discord, TikTok and Shop links. Delivery waits +A fresh network join receives a single welcome block with the large 26px Grounds +wordmark used by the network HUD, the player's name, and independent Discord, +TikTok and Shop links on separate lines. Each link includes a native Minecraft +icon (controller, music note and shopping cart); both icon and label open the link. Delivery waits five seconds after the first backend connection so client mod negotiation and -resource-pack loading can finish. Clients without a verified chat font see the +resource-pack loading can finish. Clients without a verified HUD font pack see the Grounds name instead of an unsupported glyph. Discord defaults to `https://grnds.link/discord`, matching the lobby bossbar. diff --git a/docs/superpowers/plans/2026-11-04-join-welcome.md b/docs/superpowers/plans/2026-11-04-join-welcome.md index 954c0f0..f1a1596 100644 --- a/docs/superpowers/plans/2026-11-04-join-welcome.md +++ b/docs/superpowers/plans/2026-11-04-join-welcome.md @@ -37,3 +37,5 @@ Execution: primary agent, current suitable checkout on a task branch; no separat Review ruling: use a client cookie rather than unconditional transfer suppression, because preferred-region redirects happen at PostLogin before a first backend connection. Cookie state is cosmetic, reset for each LOGIN, queried only for TRANSFER, and tracked by the exact connection. Validation: `./gradlew spotlessApply test spotlessCheck --offline --console=plain` passed after the transfer fix; 229 tests, zero failures/errors/skips. `git diff --check` passed. Live Minecraft clients and multi-proxy deployment smoke checks were not available in this session. + +Visual follow-up: replace the small chat G plus text heading with the verified large HUD wordmark, switch readiness to PlayerHudAppearanceQuery, reserve space for the tall bitmap, and put native icon+label links on separate rows. Updated existing link/layout tests first (three expected failures), then implemented; full Gradle test and formatting checks passed. Stage follow-up is within the existing deployment authorization. diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md index 82f9fe0..cbd3dc7 100644 --- a/docs/superpowers/specs/2026-11-04-join-welcome-design.md +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -1,6 +1,6 @@ # Join welcome -Show a compact Grounds welcome once on a fresh network connection. Reuse the existing chat logo when the client's chat resource pack is verified; otherwise show the Grounds name. Put clickable Discord, TikTok and Shop links below it. Discord uses the lobby bossbar's `https://grnds.link/discord`; the other two links use explicitly documented, replaceable placeholders. +Show a compact Grounds welcome once on a fresh network connection. Use the existing large 26px Grounds HUD wordmark when the client's HUD resource pack is verified; otherwise show the Grounds name. Put Discord, TikTok and Shop on independent clickable rows below it, with native Minecraft controller, music-note and cart icons. Discord uses the lobby bossbar's `https://grnds.link/discord`; the other two links use explicitly documented, replaceable placeholders. Keep the original voice request: explain party, game and nearby voice and link to Simple Voice Chat installation only when the client has not announced the mod. Disabled voice and failed UDP connections must not be treated as missing installation. Observe client channel registration and the voice handshake without consuming its messages. @@ -9,3 +9,7 @@ Approach: the existing proxy plugin owns network joins and translations, so rend User supplied the layout and selected the three links and placeholder policy. Implementation choices: five-second grace period, standard text fallback, configurable link environment variables. No publishing is part of this change. Review fix: an unconditional transfer skip loses the first welcome when PreferredRegionListener redirects before backend selection. Regression tests cover both this route and transfers during the delivery delay, delivered markers, and stale cookie replies. + +Visual correction: the user requested the large wordmark used by the scoreboard rather than the compact G chat icon. Use the existing approved `grounds:tab` U+E000 wordmark at 26px height, with enough normal chat lines reserved around its ascent. Show native 🎮 / 🎵 / 🛒 icons on separate linked rows. The supplied Cytooxien screenshot informs hierarchy only; no competitor assets or exact layout are copied. Native Minecraft 26.3 glyphs and the exact active Stage font pack were inspected before use. + +Keep the full pack-ready welcome, including voice help, within the default ten closed-chat rows: two empty rows, logo anchor, one empty row, greeting, three links, one short voice-description row, installation link. Do not append a trailing newline. This reserves the tall bitmap without pushing its anchor out of view. 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 0506d44..b82d7a1 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -23,7 +23,6 @@ import gg.grounds.BuildInfo import gg.grounds.i18n.Translations import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.NetworkPlayerCounts -import gg.grounds.proxy.api.PlayerChatAppearanceQuery import gg.grounds.proxy.api.PlayerDisplayQuery import gg.grounds.proxy.api.PlayerHudAppearanceQuery import gg.grounds.proxy.api.PlayerLocaleQuery @@ -324,9 +323,9 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { .schedule() }, logoReady = { player -> - ProxyServiceRegistry.get(PlayerChatAppearanceQuery::class.java) + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) ?.appearanceOf(player.uniqueId) - ?.chatFontReady == true + ?.ready == true }, ) welcome = welcomeListener diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index f838e0f..74fcd9a 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -3,6 +3,7 @@ package gg.grounds.proxy.velocity.welcome import gg.grounds.i18n.MessageKey import gg.grounds.i18n.Palette import gg.grounds.i18n.Translations +import gg.grounds.proxy.velocity.tab.TabGlyphs import java.util.Locale import net.kyori.adventure.key.Key import net.kyori.adventure.text.Component @@ -44,39 +45,38 @@ internal class WelcomeMessage( logoReady: Boolean, voiceInstalled: Boolean, ): Component { - val brand = Component.text("GROUNDS", Palette.TEXT, TextDecoration.BOLD) - val result = Component.text().appendNewline() - if (logoReady) { - result - .append( - Component.text("\uE100") - .font(Key.key("grounds:chat")) - .color(TextColor.color(0xFFFFFF)) - ) - .append(Component.space()) - } + val brand = + if (logoReady) + Component.text(TabGlyphs.LOGO.toString()) + .font(TabGlyphs.FONT) + .color(TextColor.color(0xFFFFFF)) + else Component.text("GROUNDS", Palette.TEXT, TextDecoration.BOLD) + val result = Component.text().font(Key.key("minecraft:default")) + // Reserve room around the 26px wordmark while fitting the default ten-line closed chat. + repeat(if (logoReady) 2 else 1) { result.appendNewline() } + result.append(brand).appendNewline() + if (logoReady) result.appendNewline() result - .append(brand) - .appendNewline() .append(messages.render(Copy.GREETING, locale, "player" to playerName)) .appendNewline() + .append(communityLink("🎮", "Discord", links.discord)) .appendNewline() - .append(link(Component.text("Discord", Palette.ACCENT), links.discord)) - .append(Component.text(" · ", Palette.TEXT_FAINT)) - .append(link(Component.text("TikTok", Palette.ACCENT), links.tiktok)) - .append(Component.text(" · ", Palette.TEXT_FAINT)) - .append(link(Component.text("Shop", Palette.ACCENT), links.shop)) + .append(communityLink("🎵", "TikTok", links.tiktok)) + .appendNewline() + .append(communityLink("🛒", "Shop", links.shop)) if (!voiceInstalled) { result - .appendNewline() .appendNewline() .append(messages.render(Copy.VOICE, locale)) .appendNewline() .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)) } - return result.appendNewline().build() + return result.build() } + private fun communityLink(icon: String, label: String, url: String): Component = + link(Component.text("$icon $label", Palette.ACCENT), url) + private fun link(label: Component, url: String): Component = label .clickEvent(ClickEvent.openUrl(url)) diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index ba534b3..c67c9fb 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -79,5 +79,5 @@ proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. welcome.greeting=Welcome to Grounds, ! -welcome.voice=Voice chat for parties, games\nand players nearby. +welcome.voice=Voice chat for parties, games and players nearby. welcome.install=→ Click here to install the voice chat mod. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 0e8bcdc..ec18628 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -78,5 +78,5 @@ proxy.entry.current= (): (du bist hier)Klicke auf einen Proxy, um zu wechseln. # Welcome block welcome.greeting=Willkommen auf Grounds, ! -welcome.voice=Voice Chat für Partys, Spielrunden\nund Spieler in deiner Nähe. +welcome.voice=Voice Chat für Partys, Runden und Spieler in der Nähe. welcome.install=→ Klicke hier, um die Voice-Chat-Mod zu installieren. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 0fa8e36..b0f0251 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -78,5 +78,5 @@ proxy.entry.current= (): (estás aquí)Haz clic en un proxy para cambiar. # Welcome block welcome.greeting=¡Bienvenido a Grounds, ! -welcome.voice=Chat de voz para grupos, partidas\ny jugadores cercanos. +welcome.voice=Chat de voz para grupos, partidas y jugadores cercanos. welcome.install=→ Haz clic aquí para instalar el mod de chat de voz. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 2d6e450..cbb009f 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -78,5 +78,5 @@ proxy.entry.current= () : (vous êtes ici)Cliquez sur un proxy pour changer. # Welcome block welcome.greeting=Bienvenue sur Grounds, ! -welcome.voice=Le chat vocal pour les groupes, les parties\net les joueurs à proximité. +welcome.voice=Chat vocal pour groupes, parties et joueurs proches. welcome.install=→ Clique ici pour installer le mod de chat vocal. diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt index 6235ad1..bdf3af2 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt @@ -307,7 +307,7 @@ class WelcomeListenerTest { connect(first) ready = true pending[0].run() - assertTrue(plain(first).contains('\uE100')) + assertTrue(plain(first).contains('\uE000')) val second = client() connect(second) listener.close() diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index c0419f9..ae3ff0d 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -25,6 +25,9 @@ class WelcomeMessageTest { @Test fun `links below the brand have independent click targets`() { val message = renderer.render("Alex", Locale.ENGLISH, false, true) + val clickable = nodes(message).filter { url(it) != null } + assertEquals(listOf("🎮 Discord", "🎵 TikTok", "🛒 Shop"), clickable.map(::plain)) + assertTrue(plain(message).contains("🎮 Discord\n🎵 TikTok\n🛒 Shop")) assertTrue(plain(message).indexOf("Grounds") < plain(message).indexOf("Discord")) assertEquals( listOf( @@ -42,6 +45,11 @@ class WelcomeMessageTest { listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { val missing = renderer.render("Alex", locale, false, false) val installed = renderer.render("Alex", locale, false, true) + val full = renderer.render("Alex", locale, true, false) + assertTrue( + plain(full).lines().size <= 10, + "The full welcome must fit the default closed chat: $locale", + ) assertTrue( nodes(missing).any { url(it) == "https://modrepo.de/minecraft/voicechat/wiki/installation" @@ -64,10 +72,11 @@ class WelcomeMessageTest { val fallback = renderer.render("Alex", Locale.ENGLISH, false, true) val packed = renderer.render("Alex", Locale.ENGLISH, true, true) assertTrue(plain(fallback).contains("Alex")) - assertFalse(plain(fallback).contains('\uE100')) + assertFalse(plain(fallback).contains('\uE000')) + assertFalse(plain(packed).contains("GROUNDS"), "The wordmark replaces the text heading") assertTrue( nodes(packed).any { - it.font() == Key.key("grounds:chat") && plain(it).contains('\uE100') + it.font() == Key.key("grounds:tab") && plain(it).contains('\uE000') } ) } From 30fe0c9da145d71f70674ad7285893e3efdbc972 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 20:25:17 +0100 Subject: [PATCH 03/13] feat: render pixel brand logos in a horizontal welcome row --- README.md | 7 +++- .../specs/2026-11-04-join-welcome-design.md | 2 + .../proxy/velocity/GroundsProxyPlugin.kt | 8 ++++ .../proxy/velocity/welcome/WelcomeGlyphs.kt | 25 ++++++++++++ .../proxy/velocity/welcome/WelcomeListener.kt | 2 + .../proxy/velocity/welcome/WelcomeMessage.kt | 40 ++++++++++++++++--- .../velocity/welcome/WelcomeMessageTest.kt | 22 ++++++++-- 7 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt diff --git a/README.md b/README.md index d72af94..2a280b0 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ Local first, always — a player on this proxy is already in memory and costs no A fresh network join receives a single welcome block with the large 26px Grounds wordmark used by the network HUD, the player's name, and independent Discord, -TikTok and Shop links on separate lines. Each link includes a native Minecraft -icon (controller, music note and shopping cart); both icon and label open the link. Delivery waits +TikTok and Shop links in one horizontal row. The resource pack supplies sharp +pixel-art brand icons (Discord, TikTok and a shopping bag); both icon and label +open the link. Delivery waits five seconds after the first backend connection so client mod negotiation and resource-pack loading can finish. Clients without a verified HUD font pack see the Grounds name instead of an unsupported glyph. @@ -246,3 +247,5 @@ maintenance is active. New maintenance drafts default to an empty override. ``` Published on tag as `gg.grounds:plugin-proxy-api` (consume this) and `plugin-proxy-velocity`, and as `ghcr.io/groundsgg/plugin-proxy` — the image carries the shaded JAR at `/jar/plugin.jar` for the `plugin-velocity-jar` Helm chart. + +Welcome bitmap icons use `grounds:welcome` U+E400/U+E401/U+E402. Set `WELCOME_FONT_PACK_SHA1S` to the verified platform-pack hashes containing these glyphs and restrict `HUD_FONT_PACK_SHA1S` to that same set. Icons remain disabled unless every HUD-ready hash is covered; unknown packs show plain labels. Existing chat, sidebar and HUD pack allowlists must include a newly activated platform pack as appropriate. diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md index cbd3dc7..a5fbe2b 100644 --- a/docs/superpowers/specs/2026-11-04-join-welcome-design.md +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -13,3 +13,5 @@ Review fix: an unconditional transfer skip loses the first welcome when Preferre Visual correction: the user requested the large wordmark used by the scoreboard rather than the compact G chat icon. Use the existing approved `grounds:tab` U+E000 wordmark at 26px height, with enough normal chat lines reserved around its ascent. Show native 🎮 / 🎵 / 🛒 icons on separate linked rows. The supplied Cytooxien screenshot informs hierarchy only; no competitor assets or exact layout are copied. Native Minecraft 26.3 glyphs and the exact active Stage font pack were inspected before use. Keep the full pack-ready welcome, including voice help, within the default ten closed-chat rows: two empty rows, logo anchor, one empty row, greeting, three links, one short voice-description row, installation link. Do not append a trailing newline. This reserves the tall bitmap without pushing its anchor out of view. + +Pixel-logo follow-up: replace the native Unicode icons with actual Discord/TikTok silhouettes and a shopping-bag bitmap. Use a single horizontal row; each icon and name share a click target. The 14px glyphs have 11px ascent, with an empty normal row above and below. A full pack-ready welcome still uses at most ten rows. Only verified new HUD pack hashes covered by WELCOME_FONT_PACK_SHA1S may enable icons. Stage uses an immutable resource-pack prerelease pin; global Edge and Stable channels remain untouched. 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 b82d7a1..7028383 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -56,6 +56,7 @@ import gg.grounds.proxy.velocity.tab.PlayerDisplayQueryImpl import gg.grounds.proxy.velocity.tab.TabList import gg.grounds.proxy.velocity.tab.TabParties import gg.grounds.proxy.velocity.tab.VoicePresences +import gg.grounds.proxy.velocity.welcome.WelcomeGlyphs import gg.grounds.proxy.velocity.welcome.WelcomeLinks import gg.grounds.proxy.velocity.welcome.WelcomeListener import gg.grounds.proxy.velocity.welcome.WelcomeMessage @@ -312,6 +313,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { proxy.channelRegistrar.register(lobbyIdentifier) proxy.eventManager.register(this, LobbyCountsListener(lobbyIdentifier, service, logger)) + val pixelIconsEnabled = WelcomeGlyphs.enabled(System.getenv()) val welcomeListener = WelcomeListener( messages, @@ -327,6 +329,12 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { ?.appearanceOf(player.uniqueId) ?.ready == true }, + iconsReady = { player -> + pixelIconsEnabled && + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.ready == true + }, ) welcome = welcomeListener proxy.channelRegistrar.register( diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt new file mode 100644 index 0000000..a9168ca --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt @@ -0,0 +1,25 @@ +package gg.grounds.proxy.velocity.welcome + +import net.kyori.adventure.key.Key + +internal object WelcomeGlyphs { + val FONT = Key.key("grounds:welcome") + const val DISCORD = '\uE400' + const val TIKTOK = '\uE401' + const val SHOP = '\uE402' + + /** HUD readiness is sufficient only if every approved HUD pack contains these glyphs. */ + fun supportedBy(hudPacks: Set, welcomePacks: Set): Boolean = + hudPacks.isNotEmpty() && hudPacks.all { it in welcomePacks } + + fun enabled(environment: Map): Boolean { + fun hashes(name: String) = + environment[name] + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + return supportedBy(hashes("HUD_FONT_PACK_SHA1S"), hashes("WELCOME_FONT_PACK_SHA1S")) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt index 80b2609..f449c40 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt @@ -24,6 +24,7 @@ internal class WelcomeListener( private val renderer: WelcomeMessage, private val schedule: (Runnable) -> ScheduledTask, private val logoReady: (Player) -> Boolean, + private val iconsReady: (Player) -> Boolean = { false }, ) : AutoCloseable { // Connection objects prevent a delayed task or old disconnect from affecting a reconnect. private val sessions = ConcurrentHashMap() @@ -130,6 +131,7 @@ internal class WelcomeListener( messages.localeOf(player), logoReady(player), join.voiceInstalled, + iconsReady(player), ) ) join.welcomed = true diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index 74fcd9a..b1a389e 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -39,11 +39,13 @@ internal class WelcomeMessage( INSTALL("welcome.install"), } + @JvmOverloads fun render( playerName: String, locale: Locale, logoReady: Boolean, voiceInstalled: Boolean, + iconsReady: Boolean = false, ): Component { val brand = if (logoReady) @@ -59,13 +61,24 @@ internal class WelcomeMessage( result .append(messages.render(Copy.GREETING, locale, "player" to playerName)) .appendNewline() - .append(communityLink("🎮", "Discord", links.discord)) .appendNewline() - .append(communityLink("🎵", "TikTok", links.tiktok)) - .appendNewline() - .append(communityLink("🛒", "Shop", links.shop)) + .append( + communityLink( + WelcomeGlyphs.DISCORD, + "Discord", + links.discord, + iconsReady && logoReady, + ) + ) + .append(Component.text(" ")) + .append( + communityLink(WelcomeGlyphs.TIKTOK, "TikTok", links.tiktok, iconsReady && logoReady) + ) + .append(Component.text(" ")) + .append(communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady)) if (!voiceInstalled) { result + .appendNewline() .appendNewline() .append(messages.render(Copy.VOICE, locale)) .appendNewline() @@ -74,8 +87,23 @@ internal class WelcomeMessage( return result.build() } - private fun communityLink(icon: String, label: String, url: String): Component = - link(Component.text("$icon $label", Palette.ACCENT), url) + private fun communityLink( + icon: Char, + label: String, + url: String, + iconsReady: Boolean, + ): Component { + val row = Component.text() + if (iconsReady) { + row.append( + Component.text(icon.toString()) + .font(WelcomeGlyphs.FONT) + .color(TextColor.color(0xFFFFFF)) + ) + .append(Component.space()) + } + return link(row.append(Component.text(label, Palette.ACCENT)).build(), url) + } private fun link(label: Component, url: String): Component = label diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index ae3ff0d..92d39f1 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -24,10 +24,14 @@ class WelcomeMessageTest { @Test fun `links below the brand have independent click targets`() { - val message = renderer.render("Alex", Locale.ENGLISH, false, true) + val message = renderer.render("Alex", Locale.ENGLISH, true, true, true) val clickable = nodes(message).filter { url(it) != null } - assertEquals(listOf("🎮 Discord", "🎵 TikTok", "🛒 Shop"), clickable.map(::plain)) - assertTrue(plain(message).contains("🎮 Discord\n🎵 TikTok\n🛒 Shop")) + assertEquals( + listOf("\uE400 Discord", "\uE401 TikTok", "\uE402 Shop"), + clickable.map(::plain), + ) + assertTrue(plain(message).contains("\uE400 Discord \uE401 TikTok \uE402 Shop")) + assertTrue(nodes(message).any { it.font() == Key.key("grounds:welcome") }) assertTrue(plain(message).indexOf("Grounds") < plain(message).indexOf("Discord")) assertEquals( listOf( @@ -45,7 +49,7 @@ class WelcomeMessageTest { listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { val missing = renderer.render("Alex", locale, false, false) val installed = renderer.render("Alex", locale, false, true) - val full = renderer.render("Alex", locale, true, false) + val full = renderer.render("Alex", locale, true, false, true) assertTrue( plain(full).lines().size <= 10, "The full welcome must fit the default closed chat: $locale", @@ -81,6 +85,16 @@ class WelcomeMessageTest { ) } + @Test + fun `unverified icon packs render readable labels without private glyphs`() { + val fallback = plain(renderer.render("Alex", Locale.ENGLISH, true, true, false)) + assertTrue(fallback.contains("Discord TikTok Shop")) + assertFalse(fallback.any { it in '\uE400'..'\uE402' }) + assertFalse(WelcomeGlyphs.supportedBy(setOf("old", "new"), setOf("new"))) + assertTrue(WelcomeGlyphs.supportedBy(setOf("new"), setOf("new"))) + assertFalse(WelcomeGlyphs.supportedBy(emptySet(), setOf("new"))) + } + @Test fun `configured links replace the placeholders`() { val configured = From bdbf60358cc897e6b379bc1e95a1012a49066336 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 21:23:37 +0100 Subject: [PATCH 04/13] fix: center welcome block and apply Minecraft Mini-CD colors --- .../specs/2026-11-04-join-welcome-design.md | 2 + .../proxy/velocity/welcome/WelcomeLayout.kt | 84 +++++++++++++++++++ .../proxy/velocity/welcome/WelcomeMessage.kt | 66 ++++++++++----- .../gg/grounds/proxy/messages.properties | 6 +- .../gg/grounds/proxy/messages_de.properties | 6 +- .../gg/grounds/proxy/messages_es.properties | 6 +- .../gg/grounds/proxy/messages_fr.properties | 6 +- .../velocity/welcome/WelcomeMessageTest.kt | 16 ++++ 8 files changed, 157 insertions(+), 35 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md index a5fbe2b..443181f 100644 --- a/docs/superpowers/specs/2026-11-04-join-welcome-design.md +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -15,3 +15,5 @@ Visual correction: the user requested the large wordmark used by the scoreboard Keep the full pack-ready welcome, including voice help, within the default ten closed-chat rows: two empty rows, logo anchor, one empty row, greeting, three links, one short voice-description row, installation link. Do not append a trailing newline. This reserves the tall bitmap without pushing its anchor out of view. Pixel-logo follow-up: replace the native Unicode icons with actual Discord/TikTok silhouettes and a shopping-bag bitmap. Use a single horizontal row; each icon and name share a click target. The 14px glyphs have 11px ascent, with an empty normal row above and below. A full pack-ready welcome still uses at most ten rows. Only verified new HUD pack hashes covered by WELCOME_FONT_PACK_SHA1S may enable icons. Stage uses an immutable resource-pack prerelease pin; global Edge and Stable channels remain untouched. + +Centered Mini-CD follow-up: center every visible line within the default 320 GUI-pixel chat column. The resulting space at either edge insets the block; the client does not expose custom chat-width settings to the server. Use the existing verified tab-font pixel spacing, and ordinary spaces for clients without the pack. Measure native Latin advances and the actual wordmark/icon advances, including punctuation and localized accents. Preserve the ten-row height, individual link targets, mod detection and transfer behavior. Use gold.primary #E7B84B for link labels and the Grounds name, text.primary #F3F0EA for the greeting, text.secondary #C1B9AA for voice help and energy.accent #67D9EC for the installation action. Keep the original wordmark and social bitmap colours neutral-white modulated as required by the Mini-CD. No new pack is needed. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt new file mode 100644 index 0000000..b0e829b --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt @@ -0,0 +1,84 @@ +package gg.grounds.proxy.velocity.welcome + +import gg.grounds.proxy.velocity.tab.TabGlyphs +import gg.grounds.proxy.velocity.tab.TabSpaces +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.TextDecoration + +/** + * Align within the default 320px chat column; the server cannot read client chat-width settings. + */ +internal object WelcomeLayout { + private val defaultFont = Key.key("minecraft:default") + + fun center(line: Component, packReady: Boolean): Component { + val inset = ((320 - width(line)) / 2).coerceAtLeast(0) + val padding = + if (packReady) Component.text(TabSpaces.of(inset)).font(TabGlyphs.FONT) + else Component.text(" ".repeat(inset / 4)).font(defaultFont) + // Padding is a sibling so it cannot inherit a link target or tint the bitmap. + return Component.empty().append(padding).append(line) + } + + private fun width( + component: Component, + inheritedFont: Key = defaultFont, + inheritedBold: Boolean = false, + ): Int { + val font = component.font() ?: inheritedFont + val bold = + when (component.decoration(TextDecoration.BOLD)) { + TextDecoration.State.TRUE -> true + TextDecoration.State.FALSE -> false + TextDecoration.State.NOT_SET -> inheritedBold + } + val own = + (component as? TextComponent)?.content()?.sumOf { ch -> + when { + font == TabGlyphs.FONT && ch == TabGlyphs.LOGO -> TabGlyphs.LOGO_ADVANCE + font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.SHOP -> 13 + font == WelcomeGlyphs.FONT && ch in '\uE400'..'\uE401' -> 14 + else -> advance(ch) + if (bold && ch != ' ') 1 else 0 + } + } ?: 0 + return own + component.children().sumOf { width(it, font, bold) } + } + + /** Native Java bitmap advances, including the Latin accents used by the welcome locales. */ + private fun advance(ch: Char): Int = + when (ch) { + ' ' -> 4 + '!', + '\'', + ',', + '.', + ':', + ';', + 'i', + '|', + '¡' -> 2 + '`', + 'l', + 'í' -> 3 + '"', + '(', + ')', + '*', + 'I', + '[', + ']', + 't', + '{', + '}' -> 4 + '<', + '>', + 'f', + 'k' -> 5 + '@', + '~' -> 7 + '→' -> 8 + else -> 6 + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index b1a389e..7f69f8b 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -1,7 +1,6 @@ package gg.grounds.proxy.velocity.welcome import gg.grounds.i18n.MessageKey -import gg.grounds.i18n.Palette import gg.grounds.i18n.Translations import gg.grounds.proxy.velocity.tab.TabGlyphs import java.util.Locale @@ -52,37 +51,57 @@ internal class WelcomeMessage( Component.text(TabGlyphs.LOGO.toString()) .font(TabGlyphs.FONT) .color(TextColor.color(0xFFFFFF)) - else Component.text("GROUNDS", Palette.TEXT, TextDecoration.BOLD) + else Component.text("GROUNDS", GOLD, TextDecoration.BOLD) val result = Component.text().font(Key.key("minecraft:default")) // Reserve room around the 26px wordmark while fitting the default ten-line closed chat. repeat(if (logoReady) 2 else 1) { result.appendNewline() } - result.append(brand).appendNewline() + result.append(WelcomeLayout.center(brand, logoReady)).appendNewline() if (logoReady) result.appendNewline() result - .append(messages.render(Copy.GREETING, locale, "player" to playerName)) - .appendNewline() - .appendNewline() .append( - communityLink( - WelcomeGlyphs.DISCORD, - "Discord", - links.discord, - iconsReady && logoReady, + WelcomeLayout.center( + messages.render(Copy.GREETING, locale, "player" to playerName), + logoReady, ) ) - .append(Component.text(" ")) - .append( - communityLink(WelcomeGlyphs.TIKTOK, "TikTok", links.tiktok, iconsReady && logoReady) - ) - .append(Component.text(" ")) - .append(communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady)) + .appendNewline() + .appendNewline() + val community = + Component.empty() + .append( + communityLink( + WelcomeGlyphs.DISCORD, + "Discord", + links.discord, + iconsReady && logoReady, + ) + ) + .append(Component.text(" ")) + .append( + communityLink( + WelcomeGlyphs.TIKTOK, + "TikTok", + links.tiktok, + iconsReady && logoReady, + ) + ) + .append(Component.text(" ")) + .append( + communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady) + ) + result.append(WelcomeLayout.center(community, logoReady)) if (!voiceInstalled) { result .appendNewline() .appendNewline() - .append(messages.render(Copy.VOICE, locale)) + .append(WelcomeLayout.center(messages.render(Copy.VOICE, locale), logoReady)) .appendNewline() - .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)) + .append( + WelcomeLayout.center( + link(messages.render(Copy.INSTALL, locale), INSTALL_URL), + logoReady, + ) + ) } return result.build() } @@ -102,15 +121,16 @@ internal class WelcomeMessage( ) .append(Component.space()) } - return link(row.append(Component.text(label, Palette.ACCENT)).build(), url) + return link(row.append(Component.text(label, GOLD)).build(), url) } private fun link(label: Component, url: String): Component = - label - .clickEvent(ClickEvent.openUrl(url)) - .hoverEvent(Component.text(url, Palette.TEXT_MUTED)) + label.clickEvent(ClickEvent.openUrl(url)).hoverEvent(Component.text(url, SAND)) companion object { + // Minecraft Mini-CD: gold.primary and text.secondary, not the web UI palette. + private val GOLD = TextColor.color(0xE7B84B) + private val SAND = TextColor.color(0xC1B9AA) const val INSTALL_URL = "https://modrepo.de/minecraft/voicechat/wiki/installation" } } diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index c67c9fb..018e987 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -78,6 +78,6 @@ proxy.current=You are on . proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. -welcome.greeting=Welcome to Grounds, ! -welcome.voice=Voice chat for parties, games and players nearby. -welcome.install=→ Click here to install the voice chat mod. +welcome.greeting=Welcome to <#E7B84B>Grounds, ! +welcome.voice=<#C1B9AA>Voice chat for parties, games and players nearby. +welcome.install=<#67D9EC>→ Click here to install the voice chat mod. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index ec18628..8b7c52e 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -77,6 +77,6 @@ proxy.entry=Zu wechseln (): proxy.entry.current= (): (du bist hier) proxy.usage=Klicke auf einen Proxy, um zu wechseln. # Welcome block -welcome.greeting=Willkommen auf Grounds, ! -welcome.voice=Voice Chat für Partys, Runden und Spieler in der Nähe. -welcome.install=→ Klicke hier, um die Voice-Chat-Mod zu installieren. +welcome.greeting=Willkommen auf <#E7B84B>Grounds, ! +welcome.voice=<#C1B9AA>Voice Chat für Partys, Runden und Spieler in der Nähe. +welcome.install=<#67D9EC>→ Klicke hier, um die Voice-Chat-Mod zu installieren. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index b0f0251..cd8de63 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -77,6 +77,6 @@ proxy.entry=Cambiar a (): proxy.entry.current= (): (estás aquí) proxy.usage=Haz clic en un proxy para cambiar. # Welcome block -welcome.greeting=¡Bienvenido a Grounds, ! -welcome.voice=Chat de voz para grupos, partidas y jugadores cercanos. -welcome.install=→ Haz clic aquí para instalar el mod de chat de voz. +welcome.greeting=¡Bienvenido a <#E7B84B>Grounds, ! +welcome.voice=<#C1B9AA>Chat de voz para grupos, partidas y jugadores cercanos. +welcome.install=<#67D9EC>→ Haz clic aquí para instalar el mod de chat de voz. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index cbb009f..234ec2f 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -77,6 +77,6 @@ proxy.entry=Aller vers () : proxy.entry.current= () : (vous êtes ici) proxy.usage=Cliquez sur un proxy pour changer. # Welcome block -welcome.greeting=Bienvenue sur Grounds, ! -welcome.voice=Chat vocal pour groupes, parties et joueurs proches. -welcome.install=→ Clique ici pour installer le mod de chat vocal. +welcome.greeting=Bienvenue sur <#E7B84B>Grounds, ! +welcome.voice=<#C1B9AA>Chat vocal pour groupes, parties et joueurs proches. +welcome.install=<#67D9EC>→ Clique ici pour installer le mod de chat vocal. diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index 92d39f1..b9e8204 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -22,6 +22,22 @@ class WelcomeMessageTest { private fun plain(component: Component) = PlainTextComponentSerializer.plainText().serialize(component) + @Test + fun `each welcome line is inset and shares the same visual center`() { + val lines = plain(renderer.render("Alex", Locale.GERMAN, true, false, true)).lines() + // Widths measured from the native Minecraft font and the active bitmap pack: + // wordmark 129, greeting 145, social row 169, voice 278, installation 260px. + val expectedInsets = mapOf(2 to 95, 4 to 87, 6 to 75, 8 to 21, 9 to 30) + for ((row, expected) in expectedInsets) { + val prefix = lines[row].takeWhile { it in '\uE010'..'\uE025' } + val inset = prefix.sumOf { 1 shl ((it.code - 0xE010) / 2) } + assertEquals(expected, inset, "Row $row should be centered without touching the edge") + } + val fallback = renderer.render("Alex", Locale.GERMAN, false, true, false) + assertTrue(plain(fallback).lines().filter(String::isNotBlank).all { it.startsWith(" ") }) + assertFalse(plain(fallback).any { it in '\uE000'..'\uF8FF' }) + } + @Test fun `links below the brand have independent click targets`() { val message = renderer.render("Alex", Locale.ENGLISH, true, true, true) From 924328cb2d82ed33eef020ede5e92c6997d47c3b Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 21:36:35 +0100 Subject: [PATCH 05/13] fix: pad welcome graphics and shorten the greeting --- .../specs/2026-11-04-join-welcome-design.md | 2 ++ .../proxy/velocity/welcome/WelcomeLayout.kt | 3 +- .../proxy/velocity/welcome/WelcomeMessage.kt | 17 ++++++---- .../gg/grounds/proxy/messages.properties | 6 ++-- .../gg/grounds/proxy/messages_de.properties | 6 ++-- .../gg/grounds/proxy/messages_es.properties | 6 ++-- .../gg/grounds/proxy/messages_fr.properties | 6 ++-- .../velocity/welcome/WelcomeListenerTest.kt | 20 ++++++++---- .../velocity/welcome/WelcomeMessageTest.kt | 31 +++++++++++++++++-- 9 files changed, 69 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md index 443181f..9a2808a 100644 --- a/docs/superpowers/specs/2026-11-04-join-welcome-design.md +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -17,3 +17,5 @@ Keep the full pack-ready welcome, including voice help, within the default ten c Pixel-logo follow-up: replace the native Unicode icons with actual Discord/TikTok silhouettes and a shopping-bag bitmap. Use a single horizontal row; each icon and name share a click target. The 14px glyphs have 11px ascent, with an empty normal row above and below. A full pack-ready welcome still uses at most ten rows. Only verified new HUD pack hashes covered by WELCOME_FONT_PACK_SHA1S may enable icons. Stage uses an immutable resource-pack prerelease pin; global Edge and Stable channels remain untouched. Centered Mini-CD follow-up: center every visible line within the default 320 GUI-pixel chat column. The resulting space at either edge insets the block; the client does not expose custom chat-width settings to the server. Use the existing verified tab-font pixel spacing, and ordinary spaces for clients without the pack. Measure native Latin advances and the actual wordmark/icon advances, including punctuation and localized accents. Preserve the ten-row height, individual link targets, mod detection and transfer behavior. Use gold.primary #E7B84B for link labels and the Grounds name, text.primary #F3F0EA for the greeting, text.secondary #C1B9AA for voice help and energy.accent #67D9EC for the installation action. Keep the original wordmark and social bitmap colours neutral-white modulated as required by the Mini-CD. No new pack is needed. + +Chat-background spacing correction: the user's actual client screenshot shows social bitmaps extending below the final chat background when the mod is installed. Always append a blank footer row. With verified wordmark and no voice help, reserve three leading rows for extra top margin. With voice help, retain two leading rows and combine the short localized voice description with a separate cyan installation link on one centered row so the full welcome still fits ten closed-chat rows including footer. Social icons retain an empty row above and below. Shorten the greeting to “Willkommen, !” and equivalents because the wordmark already names Grounds; highlight the name gold. A generic welcome is appropriate for both first-time and returning players and does not imply new player-history detection. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt index b0e829b..90cef1e 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt @@ -58,7 +58,8 @@ internal object WelcomeLayout { ';', 'i', '|', - '¡' -> 2 + '¡', + '·' -> 2 '`', 'l', 'í' -> 3 diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index 7f69f8b..13d2787 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -53,8 +53,10 @@ internal class WelcomeMessage( .color(TextColor.color(0xFFFFFF)) else Component.text("GROUNDS", GOLD, TextDecoration.BOLD) val result = Component.text().font(Key.key("minecraft:default")) - // Reserve room around the 26px wordmark while fitting the default ten-line closed chat. - repeat(if (logoReady) 2 else 1) { result.appendNewline() } + // The tall wordmark needs two reserved rows; use a third when voice help is absent. + // Together with the footer this keeps every bitmap inside the chat background. + val leadingRows = if (logoReady) (if (voiceInstalled) 3 else 2) else 1 + repeat(leadingRows) { result.appendNewline() } result.append(WelcomeLayout.center(brand, logoReady)).appendNewline() if (logoReady) result.appendNewline() result @@ -94,16 +96,19 @@ internal class WelcomeMessage( result .appendNewline() .appendNewline() - .append(WelcomeLayout.center(messages.render(Copy.VOICE, locale), logoReady)) - .appendNewline() .append( WelcomeLayout.center( - link(messages.render(Copy.INSTALL, locale), INSTALL_URL), + Component.empty() + .append(messages.render(Copy.VOICE, locale)) + .append(Component.text(" · ", SAND)) + .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)), logoReady, ) ) } - return result.build() + // Bitmap icons extend below a normal text row, so never end the background at their + // baseline. + return result.appendNewline().build() } private fun communityLink( diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index 018e987..b8f1c42 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -78,6 +78,6 @@ proxy.current=You are on . proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. -welcome.greeting=Welcome to <#E7B84B>Grounds, ! -welcome.voice=<#C1B9AA>Voice chat for parties, games and players nearby. -welcome.install=<#67D9EC>→ Click here to install the voice chat mod. +welcome.greeting=Welcome, <#E7B84B>! +welcome.voice=<#C1B9AA>Voice chat for parties, games & nearby. +welcome.install=<#67D9EC>Install diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 8b7c52e..bc33216 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -77,6 +77,6 @@ proxy.entry=Zu wechseln (): proxy.entry.current= (): (du bist hier) proxy.usage=Klicke auf einen Proxy, um zu wechseln. # Welcome block -welcome.greeting=Willkommen auf <#E7B84B>Grounds, ! -welcome.voice=<#C1B9AA>Voice Chat für Partys, Runden und Spieler in der Nähe. -welcome.install=<#67D9EC>→ Klicke hier, um die Voice-Chat-Mod zu installieren. +welcome.greeting=Willkommen, <#E7B84B>! +welcome.voice=<#C1B9AA>Voice Chat für Partys, Runden & Nähe. +welcome.install=<#67D9EC>Installieren diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index cd8de63..04d7f97 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -77,6 +77,6 @@ proxy.entry=Cambiar a (): proxy.entry.current= (): (estás aquí) proxy.usage=Haz clic en un proxy para cambiar. # Welcome block -welcome.greeting=¡Bienvenido a <#E7B84B>Grounds, ! -welcome.voice=<#C1B9AA>Chat de voz para grupos, partidas y jugadores cercanos. -welcome.install=<#67D9EC>→ Haz clic aquí para instalar el mod de chat de voz. +welcome.greeting=¡Bienvenido, <#E7B84B>! +welcome.voice=<#C1B9AA>Chat de voz: grupos, partidas y cercanía. +welcome.install=<#67D9EC>Instalar diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 234ec2f..53321fc 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -77,6 +77,6 @@ proxy.entry=Aller vers () : proxy.entry.current= () : (vous êtes ici) proxy.usage=Cliquez sur un proxy pour changer. # Welcome block -welcome.greeting=Bienvenue sur <#E7B84B>Grounds, ! -welcome.voice=<#C1B9AA>Chat vocal pour groupes, parties et joueurs proches. -welcome.install=<#67D9EC>→ Clique ici pour installer le mod de chat vocal. +welcome.greeting=Bienvenue, <#E7B84B> ! +welcome.voice=<#C1B9AA>Chat vocal : groupes, parties & proximité. +welcome.install=<#67D9EC>Installer diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt index bdf3af2..59b890d 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt @@ -20,6 +20,7 @@ import java.util.Optional import java.util.UUID import net.kyori.adventure.key.Key import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -95,6 +96,13 @@ class WelcomeListenerTest { private fun plain(client: Client) = client.received.joinToString { PlainTextComponentSerializer.plainText().serialize(it) } + private fun hasInstallLink(client: Client): Boolean { + fun linked(component: Component): Boolean = + component.clickEvent() == ClickEvent.openUrl(WelcomeMessage.INSTALL_URL) || + component.children().any(::linked) + return client.received.any(::linked) + } + private fun connect(client: Client) = listener.onServerConnected(ServerPostConnectEvent(client.player, null)) @@ -108,7 +116,7 @@ class WelcomeListenerTest { connect(client) assertEquals(1, client.received.size) assertEquals(1, pending.size) - assertTrue(plain(client).contains("install")) + assertTrue(hasInstallLink(client)) } private val cookieKey = Key.key("grounds:welcome") @@ -148,7 +156,7 @@ class WelcomeListenerTest { assertEquals(1, pending.size) pending.single().run() assertEquals(1, destination.received.size) - assertFalse(plain(destination).contains("install")) + assertFalse(hasInstallLink(destination)) assertArrayEquals(byteArrayOf(1), cookies[cookieKey]) } @@ -213,7 +221,7 @@ class WelcomeListenerTest { connect(client) pending.single().run() assertEquals(1, client.received.size) - assertFalse(plain(client).contains("install")) + assertFalse(hasInstallLink(client)) } @Test @@ -231,7 +239,7 @@ class WelcomeListenerTest { listener.onPluginMessage(event) assertTrue(event.result.isAllowed) pending.single().run() - assertFalse(plain(client).contains("install")) + assertFalse(hasInstallLink(client)) } @Test @@ -248,7 +256,7 @@ class WelcomeListenerTest { ) ) pending.single().run() - assertTrue(plain(client).contains("install")) + assertTrue(hasInstallLink(client)) } @Test @@ -281,7 +289,7 @@ class WelcomeListenerTest { pending.single().run() pending.single().run() assertEquals(1, client.received.size) - assertTrue(plain(client).contains("install")) + assertTrue(hasInstallLink(client)) } @Test diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index b9e8204..b442710 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -26,8 +26,8 @@ class WelcomeMessageTest { fun `each welcome line is inset and shares the same visual center`() { val lines = plain(renderer.render("Alex", Locale.GERMAN, true, false, true)).lines() // Widths measured from the native Minecraft font and the active bitmap pack: - // wordmark 129, greeting 145, social row 169, voice 278, installation 260px. - val expectedInsets = mapOf(2 to 95, 4 to 87, 6 to 75, 8 to 21, 9 to 30) + // wordmark 129, shorter greeting 78, social row 169, compact voice row 261px. + val expectedInsets = mapOf(2 to 95, 4 to 121, 6 to 75, 8 to 29) for ((row, expected) in expectedInsets) { val prefix = lines[row].takeWhile { it in '\uE010'..'\uE025' } val inset = prefix.sumOf { 1 shl ((it.code - 0xE010) / 2) } @@ -38,6 +38,31 @@ class WelcomeMessageTest { assertFalse(plain(fallback).any { it in '\uE000'..'\uF8FF' }) } + @Test + fun `bitmap welcome reserves background above and below the last icon row`() { + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + for (voiceInstalled in listOf(false, true)) { + val text = plain(renderer.render("Alex", locale, true, voiceInstalled, true)) + assertTrue( + text.endsWith("\n"), + "The final row must extend the chat background: $locale", + ) + assertTrue( + text.lines().size <= 10, + "Padding must not push the logo out of closed chat", + ) + if (voiceInstalled) { + assertTrue(text.startsWith("\n\n\n"), "Give the tall logo extra room above") + assertTrue(text.lines().last().isEmpty()) + } + } + } + val greeting = plain(renderer.render("Alex", Locale.GERMAN, true, true, true)) + assertTrue(greeting.contains("Willkommen, Alex!")) + assertFalse(greeting.contains("Grounds"), "The wordmark already names Grounds") + } + @Test fun `links below the brand have independent click targets`() { val message = renderer.render("Alex", Locale.ENGLISH, true, true, true) @@ -48,7 +73,7 @@ class WelcomeMessageTest { ) assertTrue(plain(message).contains("\uE400 Discord \uE401 TikTok \uE402 Shop")) assertTrue(nodes(message).any { it.font() == Key.key("grounds:welcome") }) - assertTrue(plain(message).indexOf("Grounds") < plain(message).indexOf("Discord")) + assertTrue(plain(message).indexOf("Alex") < plain(message).indexOf("Discord")) assertEquals( listOf( "https://grnds.link/discord", From d2eeb913ac6b123ece28aacc07e2e851d64d6ca3 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 22:02:29 +0100 Subject: [PATCH 06/13] fix: deliver welcome after client readiness without a fixed delay --- README.md | 25 +++- .../specs/2026-11-04-join-welcome-design.md | 2 + .../proxy/velocity/GroundsProxyPlugin.kt | 13 +- .../proxy/velocity/welcome/WelcomeListener.kt | 57 ++++++-- .../proxy/velocity/welcome/WelcomeMessage.kt | 2 +- .../gg/grounds/proxy/messages.properties | 2 +- .../gg/grounds/proxy/messages_de.properties | 2 +- .../gg/grounds/proxy/messages_es.properties | 2 +- .../gg/grounds/proxy/messages_fr.properties | 2 +- .../velocity/welcome/WelcomeListenerTest.kt | 125 ++++++++++++++++-- .../velocity/welcome/WelcomeMessageTest.kt | 4 +- .../test/scripts/WelcomeDispatchProbe.java | 64 +++++++++ .../test/scripts/verify-welcome-dispatch.sh | 13 ++ 13 files changed, 268 insertions(+), 45 deletions(-) create mode 100644 velocity/src/test/scripts/WelcomeDispatchProbe.java create mode 100755 velocity/src/test/scripts/verify-welcome-dispatch.sh diff --git a/README.md b/README.md index 2a280b0..c512a00 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,11 @@ A fresh network join receives a single welcome block with the large 26px Grounds wordmark used by the network HUD, the player's name, and independent Discord, TikTok and Shop links in one horizontal row. The resource pack supplies sharp pixel-art brand icons (Discord, TikTok and a shopping bag); both icon and label -open the link. Delivery waits -five seconds after the first backend connection so client mod negotiation and -resource-pack loading can finish. Clients without a verified HUD font pack see the +open the link. Modern clients receive it after the first backend connection and +one acknowledgement round trip, with no fixed welcome delay. Required packs have +already loaded during configuration. A `grounds:welcome_ready` cookie request is +queued after JoinGame so client join-time mod announcements precede its response. +Clients without a verified HUD font pack see the Grounds name instead of an unsupported glyph. Discord defaults to `https://grnds.link/discord`, matching the lobby bossbar. @@ -38,7 +40,8 @@ with `WELCOME_TIKTOK_URL` and `WELCOME_SHOP_URL`; `WELCOME_DISCORD_URL` can over Discord as well. Keep these values consistent across proxy deployments. Clients that have not announced Simple Voice Chat receive a short description -and a link to the official installation guide. Client channel registration or a +and a link to the official installation guide, as a complete localized sentence +followed by the installation action. Client channel registration or a voice handshake suppresses that section even if voice is disabled or UDP cannot connect. Handshake messages continue to the backend unchanged. Server switches do not repeat the welcome. A connection-scoped `grounds:welcome` client cookie @@ -50,7 +53,19 @@ pending delivery. Before rollout, check a fresh join on each proxy, a backend switch, and a proxy transfer. Check clients with the mod absent, present but disabled, and present with UDP blocked; only the first should get installation help. Also check a -declined resource pack and a disconnect/reconnect during the five-second delay. +declined resource pack and a disconnect/reconnect before the ready-cookie reply. +Only legacy protocols before 1.20.5 retain the old five-second negotiation grace. + +The installation observers capture only an in-memory bit at maximum synchronous +priority, then explicitly hand off to an asynchronous EventTask. This preserves +packet order before the ready reply without moving other plugins' handlers onto +the packet thread. Before changing the Velocity runtime, verify these dispatcher +semantics against its actual JAR: + +```sh +velocity/src/test/scripts/verify-welcome-dispatch.sh \ + velocity/build/libs/plugin-proxy-velocity.jar /path/to/deployed-velocity.jar +``` ## How the pieces fit diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md index 9a2808a..eb4648f 100644 --- a/docs/superpowers/specs/2026-11-04-join-welcome-design.md +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -19,3 +19,5 @@ Pixel-logo follow-up: replace the native Unicode icons with actual Discord/TikTo Centered Mini-CD follow-up: center every visible line within the default 320 GUI-pixel chat column. The resulting space at either edge insets the block; the client does not expose custom chat-width settings to the server. Use the existing verified tab-font pixel spacing, and ordinary spaces for clients without the pack. Measure native Latin advances and the actual wordmark/icon advances, including punctuation and localized accents. Preserve the ten-row height, individual link targets, mod detection and transfer behavior. Use gold.primary #E7B84B for link labels and the Grounds name, text.primary #F3F0EA for the greeting, text.secondary #C1B9AA for voice help and energy.accent #67D9EC for the installation action. Keep the original wordmark and social bitmap colours neutral-white modulated as required by the Mini-CD. No new pack is needed. Chat-background spacing correction: the user's actual client screenshot shows social bitmaps extending below the final chat background when the mod is installed. Always append a blank footer row. With verified wordmark and no voice help, reserve three leading rows for extra top margin. With voice help, retain two leading rows and combine the short localized voice description with a separate cyan installation link on one centered row so the full welcome still fits ten closed-chat rows including footer. Social icons retain an empty row above and below. Shorten the greeting to “Willkommen, !” and equivalents because the wordmark already names Grounds; highlight the name gold. A generic welcome is appropriate for both first-time and returning players and does not imply new player-history detection. + +Immediate-delivery follow-up: remove the fixed five-second modern-client delay. Required packs already complete in configuration. After the first backend joins and transfer dedupe is resolved false, request a separate `grounds:welcome_ready` cookie and schedule welcome without a delay on its reply. This costs a single network round trip and does not conflate an earlier Login-cookie response with Play readiness. Preserve exact connection identity, disconnect cancellation and duplicate guards. Unsolicited/stale readiness replies do not schedule anything. Protocols before1.20.5 retain the old five-second grace. Voice installation observers run at maximum priority with a synchronous memory capture, then always return EventTask.async so later handlers preserve their normal async execution. The actual deployed Velocity dispatcher is covered by a retained standalone probe that blocks a following async handler while delivering the cookie response. Native26.3 cookie handling runs on the client main thread; SVC's Fabric early-join callback sends its secret request from JoinWorld. Full localized Voice sentences now end in a period followed by a normal space and an independent installation link; native measured widths EN282/DE308/FR301/ES314px, all below320. 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 7028383..1afd57f 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -318,12 +318,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { WelcomeListener( messages, WelcomeMessage(messages, WelcomeLinks.from()), - schedule = { task -> - proxy.scheduler - .buildTask(this, task) - .delay(WelcomeListener.DELAY_SECONDS, TimeUnit.SECONDS) - .schedule() - }, + schedule = { task -> proxy.scheduler.buildTask(this, task).schedule() }, logoReady = { player -> ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) ?.appearanceOf(player.uniqueId) @@ -335,6 +330,12 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { ?.appearanceOf(player.uniqueId) ?.ready == true }, + legacySchedule = { task -> + proxy.scheduler + .buildTask(this, task) + .delay(WelcomeListener.LEGACY_GRACE_SECONDS, TimeUnit.SECONDS) + .schedule() + }, ) welcome = welcomeListener proxy.channelRegistrar.register( diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt index f449c40..3c25579 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt @@ -1,5 +1,6 @@ package gg.grounds.proxy.velocity.welcome +import com.velocitypowered.api.event.EventTask import com.velocitypowered.api.event.PostOrder import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.connection.DisconnectEvent @@ -25,6 +26,7 @@ internal class WelcomeListener( private val schedule: (Runnable) -> ScheduledTask, private val logoReady: (Player) -> Boolean, private val iconsReady: (Player) -> Boolean = { false }, + private val legacySchedule: (Runnable) -> ScheduledTask = schedule, ) : AutoCloseable { // Connection objects prevent a delayed task or old disconnect from affecting a reconnect. private val sessions = ConcurrentHashMap() @@ -34,6 +36,8 @@ internal class WelcomeListener( var voiceInstalled = false var welcomed: Boolean? = if (transfer) null else false var cookieRequested = false + var readyRequested = false + var clientReady = false var connected = false var scheduled = false var sent = false @@ -64,31 +68,45 @@ internal class WelcomeListener( @Subscribe fun onCookie(event: CookieReceiveEvent) { - if (event.originalKey != COOKIE_KEY) return + if (event.originalKey != COOKIE_KEY && event.originalKey != READY_KEY) return val player = event.player val join = sessions[player] ?: return synchronized(join) { - if (closed || sessions[player] !== join || !join.cookieRequested) return + if (closed || sessions[player] !== join) return + if (event.originalKey == READY_KEY) { + if (!join.readyRequested) return + join.readyRequested = false + join.clientReady = true + } else { + if (!join.cookieRequested) return + join.cookieRequested = false + join.welcomed = event.originalData?.contentEquals(byteArrayOf(1)) == true + } event.result = CookieReceiveEvent.ForwardResult.handled() - join.cookieRequested = false - join.welcomed = event.originalData?.contentEquals(byteArrayOf(1)) == true scheduleIfNeeded(player, join) } } - @Subscribe - fun onChannels(event: PlayerChannelRegisterEvent) { + @Subscribe(priority = Short.MAX_VALUE, async = false) + fun onChannels(event: PlayerChannelRegisterEvent): EventTask { if (event.channels.any { it.id.startsWith("voicechat:") }) markInstalled(event.player) + return continueAsync() } - @Subscribe(order = PostOrder.LAST) - fun onPluginMessage(event: PluginMessageEvent) { - if (event.identifier.id != VOICE_CHANNEL || event.data.size != 4) return - val player = event.source as? Player ?: return - markInstalled(player) + // Observe on the packet thread before async handlers can let a later cookie reply overtake us. + @Subscribe(priority = Short.MAX_VALUE, async = false) + fun onPluginMessage(event: PluginMessageEvent): EventTask { + if (event.identifier.id == VOICE_CHANNEL && event.data.size == 4) { + (event.source as? Player)?.let(::markInstalled) + } // Leave the result unchanged: the backend must receive the original handshake. + return continueAsync() } + // Velocity continues later void handlers on our current thread unless we explicitly hand off. + // Capture only the installation bit synchronously; preserve async execution for other plugins. + private fun continueAsync(): EventTask = EventTask.async {} + private fun markInstalled(player: Player) { if (closed) return val join = session(player) @@ -116,8 +134,20 @@ internal class WelcomeListener( join.scheduled ) return + if (player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5 && !join.clientReady) { + if (!join.readyRequested) { + join.readyRequested = true + // Queued after JoinGame: the client processes join-time mod announcements before + // replying on its main thread. Unlike a timer this costs only a network round trip. + player.requestCookie(READY_KEY) + } + return + } join.scheduled = true - join.task = schedule(Runnable { deliver(player, join) }) + val scheduler = + if (player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5) schedule + else legacySchedule + join.task = scheduler(Runnable { deliver(player, join) }) } private fun deliver(player: Player, join: Join) { @@ -155,7 +185,8 @@ internal class WelcomeListener( companion object { private val COOKIE_KEY = Key.key("grounds:welcome") + private val READY_KEY = Key.key("grounds:welcome_ready") const val VOICE_CHANNEL = "voicechat:request_secret" - const val DELAY_SECONDS = 5L + const val LEGACY_GRACE_SECONDS = 5L } } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index 13d2787..d2b4fa6 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -100,7 +100,7 @@ internal class WelcomeMessage( WelcomeLayout.center( Component.empty() .append(messages.render(Copy.VOICE, locale)) - .append(Component.text(" · ", SAND)) + .append(Component.space()) .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)), logoReady, ) diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index b8f1c42..f26f8dc 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -79,5 +79,5 @@ proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. welcome.greeting=Welcome, <#E7B84B>! -welcome.voice=<#C1B9AA>Voice chat for parties, games & nearby. +welcome.voice=<#C1B9AA>We support party, game and proximity voice chat. welcome.install=<#67D9EC>Install diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index bc33216..eb7b823 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -78,5 +78,5 @@ proxy.entry.current= (): (du bist hier)Klicke auf einen Proxy, um zu wechseln. # Welcome block welcome.greeting=Willkommen, <#E7B84B>! -welcome.voice=<#C1B9AA>Voice Chat für Partys, Runden & Nähe. +welcome.voice=<#C1B9AA>Wir bieten Voice Chat in Partys, Spielen und lokal. welcome.install=<#67D9EC>Installieren diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 04d7f97..2c17fb5 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -78,5 +78,5 @@ proxy.entry.current= (): (estás aquí)Haz clic en un proxy para cambiar. # Welcome block welcome.greeting=¡Bienvenido, <#E7B84B>! -welcome.voice=<#C1B9AA>Chat de voz: grupos, partidas y cercanía. +welcome.voice=<#C1B9AA>Hay chat de voz en grupos, partidas y por cercanía. welcome.install=<#67D9EC>Instalar diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 53321fc..987b952 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -78,5 +78,5 @@ proxy.entry.current= () : (vous êtes ici)Cliquez sur un proxy pour changer. # Welcome block welcome.greeting=Bienvenue, <#E7B84B> ! -welcome.voice=<#C1B9AA>Chat vocal : groupes, parties & proximité. +welcome.voice=<#C1B9AA>Le vocal marche en groupe, en jeu et à proximité. welcome.install=<#67D9EC>Installer diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt index 59b890d..ea2f0ee 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt @@ -29,6 +29,7 @@ class WelcomeListenerTest { private val pending = mutableListOf() private var cancelled = 0 private var ready = false + private var legacyScheduled = 0 private val messages = Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader) private val listener = newListener() @@ -37,21 +38,22 @@ class WelcomeListenerTest { WelcomeListener( messages, WelcomeMessage(messages), - schedule = { work -> - pending += work - fake(ScheduledTask::class.java) { name, _ -> - when (name) { - "cancel" -> { - cancelled++ - null - } - else -> null - } - } - }, + schedule = ::queue, logoReady = { ready }, + legacySchedule = { work -> + legacyScheduled++ + queue(work) + }, ) + private fun queue(work: Runnable): ScheduledTask { + pending += work + return fake(ScheduledTask::class.java) { name, _ -> + if (name == "cancel") cancelled++ + null + } + } + private class Client( val player: Player, val received: MutableList, @@ -63,6 +65,7 @@ class WelcomeListenerTest { intent: HandshakeIntent = HandshakeIntent.LOGIN, id: UUID = UUID.randomUUID(), cookies: MutableMap = mutableMapOf(), + protocol: ProtocolVersion = ProtocolVersion.MINECRAFT_1_21, ): Client { val received = mutableListOf() val requested = mutableListOf() @@ -72,7 +75,7 @@ class WelcomeListenerTest { "getUniqueId" -> id "getUsername" -> "Alex" "getHandshakeIntent" -> intent - "getProtocolVersion" -> ProtocolVersion.MINECRAFT_1_21 + "getProtocolVersion" -> protocol "storeCookie" -> { cookies[args!![0] as Key] = (args[1] as ByteArray).copyOf() null @@ -103,8 +106,99 @@ class WelcomeListenerTest { return client.received.any(::linked) } - private fun connect(client: Client) = + private val readyCookieKey = Key.key("grounds:welcome_ready") + + private fun acknowledgeReady(client: Client, proxy: WelcomeListener = listener) { + proxy.onCookie(CookieReceiveEvent(client.player, readyCookieKey, null)) + } + + private fun connect(client: Client) { + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + acknowledgeReady(client) + } + + @Test + fun `a fresh join needs only a play cookie reply before immediate scheduling`() { + val client = client() + listener.onLogin(PostLoginEvent(client.player)) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + assertEquals(listOf(readyCookieKey), client.requested) + assertEquals(0, legacyScheduled) + assertTrue(pending.isEmpty(), "Do not infer a missing mod before the client processes join") + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + acknowledgeReady(client) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, client.received.size) + assertFalse(hasInstallLink(client)) + } + + @Test + fun `a late login cookie reply cannot stand in for the play acknowledgement`() { + val client = client(HandshakeIntent.TRANSFER) + listener.onLogin(PostLoginEvent(client.player)) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + acknowledgeReady(client) + assertEquals(listOf(cookieKey), client.requested) + assertTrue(pending.isEmpty()) + listener.onCookie(CookieReceiveEvent(client.player, cookieKey, byteArrayOf(0))) + assertEquals(listOf(cookieKey, readyCookieKey), client.requested) + assertTrue(pending.isEmpty()) + acknowledgeReady(client) + assertEquals(1, pending.size) + } + + @Test + fun `legacy clients retain negotiation grace without unsupported cookies`() { + val client = client(protocol = ProtocolVersion.MINECRAFT_1_20_3) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + assertTrue(client.requested.isEmpty()) + assertEquals(1, legacyScheduled) + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + pending.single().run() + assertFalse(hasInstallLink(client)) + } + + @Test + fun `duplicate ready replies and reconnect events cannot schedule twice`() { + val client = client() + connect(client) + acknowledgeReady(client) listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + acknowledgeReady(client) + assertEquals(listOf(readyCookieKey), client.requested) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, client.received.size) + } + + @Test + fun `unsolicited or stale ready replies cannot release another connection`() { + val id = UUID.randomUUID() + val old = client(id = id) + acknowledgeReady(old) + assertTrue(pending.isEmpty()) + listener.onServerConnected(ServerPostConnectEvent(old.player, null)) + listener.onDisconnect( + DisconnectEvent(old.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val current = client(id = id) + listener.onServerConnected(ServerPostConnectEvent(current.player, null)) + acknowledgeReady(old) + assertTrue(pending.isEmpty()) + acknowledgeReady(current) + assertEquals(1, pending.size) + } @Test fun `backend reconnect events cannot schedule a second welcome`() { @@ -153,6 +247,7 @@ class WelcomeListenerTest { ) ) otherProxy.onServerConnected(ServerPostConnectEvent(destination.player, null)) + acknowledgeReady(destination, otherProxy) assertEquals(1, pending.size) pending.single().run() assertEquals(1, destination.received.size) @@ -174,6 +269,7 @@ class WelcomeListenerTest { otherProxy.onLogin(PostLoginEvent(destination.player)) otherProxy.onServerConnected(ServerPostConnectEvent(destination.player, null)) otherProxy.onCookie(CookieReceiveEvent(destination.player, cookieKey, cookies[cookieKey])) + acknowledgeReady(destination, otherProxy) assertEquals(2, pending.size) pending.forEach(Runnable::run) assertTrue(source.received.isEmpty()) @@ -196,6 +292,7 @@ class WelcomeListenerTest { val response = CookieReceiveEvent(current.player, cookieKey, null) listener.onCookie(response) assertFalse(response.result.isAllowed) + acknowledgeReady(current) assertEquals(1, pending.size) pending.single().run() assertEquals(1, current.received.size) diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index b442710..b1e0f8b 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -26,8 +26,8 @@ class WelcomeMessageTest { fun `each welcome line is inset and shares the same visual center`() { val lines = plain(renderer.render("Alex", Locale.GERMAN, true, false, true)).lines() // Widths measured from the native Minecraft font and the active bitmap pack: - // wordmark 129, shorter greeting 78, social row 169, compact voice row 261px. - val expectedInsets = mapOf(2 to 95, 4 to 121, 6 to 75, 8 to 29) + // wordmark 129, shorter greeting 78, social row 169, full-sentence voice row 308px. + val expectedInsets = mapOf(2 to 95, 4 to 121, 6 to 75, 8 to 6) for ((row, expected) in expectedInsets) { val prefix = lines[row].takeWhile { it in '\uE010'..'\uE025' } val inset = prefix.sumOf { 1 shl ((it.code - 0xE010) / 2) } diff --git a/velocity/src/test/scripts/WelcomeDispatchProbe.java b/velocity/src/test/scripts/WelcomeDispatchProbe.java new file mode 100644 index 0000000..129bec4 --- /dev/null +++ b/velocity/src/test/scripts/WelcomeDispatchProbe.java @@ -0,0 +1,64 @@ +import com.velocitypowered.api.event.*; +import com.velocitypowered.api.event.connection.*; +import com.velocitypowered.api.event.player.*; +import com.velocitypowered.api.network.*; +import com.velocitypowered.api.plugin.*; +import com.velocitypowered.api.proxy.*; +import com.velocitypowered.api.proxy.messages.*; +import com.velocitypowered.api.scheduler.*; +import com.velocitypowered.proxy.event.VelocityEventManager; +import gg.grounds.i18n.Translations; +import gg.grounds.proxy.velocity.welcome.*; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.*; +import net.kyori.adventure.text.event.ClickEvent; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.BiFunction; + +public final class WelcomeDispatchProbe { + static T fake(Class type, BiFunction fn) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(),new Class[]{type},(p,m,a)->switch(m.getName()) { + case "hashCode" -> System.identityHashCode(p);case "equals" -> p==a[0];case "toString" -> type.getSimpleName();default -> fn.apply(m.getName(),a); + })); + } + static void check(boolean value,String reason){if(!value)throw new IllegalStateException(reason);} + static boolean install(Component c){return ClickEvent.openUrl(WelcomeMessage.INSTALL_URL).equals(c.clickEvent())||c.children().stream().anyMatch(WelcomeDispatchProbe::install);} + public static final class Blocker { + final CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1); + @Subscribe(order=PostOrder.FIRST) + public void block(PluginMessageEvent event) throws InterruptedException {entered.countDown();release.await(10,TimeUnit.SECONDS);} + } + public static void main(String[] args) throws Exception { + ExecutorService workers=Executors.newFixedThreadPool(2); + var description=fake(PluginDescription.class,(n,a)->switch(n){case "getId" -> "welcome-dispatch-probe";case "getVersion","getName" -> Optional.of("probe");default -> null;}); + var plugin=fake(PluginContainer.class,(n,a)->switch(n){case "getDescription" -> description;case "getExecutorService" -> workers;default -> Optional.empty();}); + var manager=new VelocityEventManager(fake(PluginManager.class,(n,a)->Optional.of(plugin))); + var messages=Translations.forBundle("gg.grounds.proxy.messages",WelcomeDispatchProbe.class.getClassLoader()); + var sent=new CopyOnWriteArrayList();var requested=new CopyOnWriteArrayList(); + var player=fake(Player.class,(n,a)->switch(n){case "getUniqueId" -> UUID.fromString("a87fa68d-7d18-43ad-82c2-219d447a3867");case "getUsername" -> "Alex";case "getHandshakeIntent" -> HandshakeIntent.LOGIN;case "getProtocolVersion" -> ProtocolVersion.MINECRAFT_1_21;case "isActive" -> true;case "get" -> Optional.of(Locale.ENGLISH);case "requestCookie" -> {requested.add((Key)a[0]);yield null;}case "sendMessage" -> {sent.add((Component)a[0]);yield null;}default -> null;}); + var task=fake(ScheduledTask.class,(n,a)->null); + var listener=new WelcomeListener(messages,new WelcomeMessage(messages,new WelcomeLinks("https://grnds.link/discord","https://example.com/grounds/tiktok","https://example.com/grounds/shop")),work->{work.run();return task;},p->true,p->true,work->{throw new IllegalStateException("Modern client entered legacy grace");}); + Blocker blocker=new Blocker(); + try { + // Register the blocking async FIRST handler first, so a mere default/FIRST observer cannot win. + manager.registerInternally(plugin,blocker);manager.registerInternally(plugin,listener); + manager.fire(new PostLoginEvent(player)).get(2,TimeUnit.SECONDS); + manager.fire(new ServerPostConnectEvent(player,null)).get(2,TimeUnit.SECONDS); + check(requested.equals(List.of(Key.key("grounds:welcome_ready"))),"Missing play acknowledgement"); + var event=new PluginMessageEvent(player,fake(ServerConnection.class,(n,a)->null),MinecraftChannelIdentifier.from("voicechat:request_secret"),new byte[]{0,0,0,20}); + var forwarding=manager.fire(event); + check(blocker.entered.await(2,TimeUnit.SECONDS),"Async handler never started"); + check(!forwarding.isDone(),"The simulated async forwarding was not blocked"); + manager.fire(new CookieReceiveEvent(player,Key.key("grounds:welcome_ready"),null)).get(2,TimeUnit.SECONDS); + check(sent.size()==1,"No prompt welcome while later async forwarding is blocked"); + check(!install(sent.get(0)),"Cookie reply overtook the installed-mod observer"); + check(event.getResult().isAllowed(),"Observer consumed the actual handshake"); + blocker.release.countDown();forwarding.get(2,TimeUnit.SECONDS); + manager.fire(new CookieReceiveEvent(player,Key.key("grounds:welcome_ready"),null)).get(2,TimeUnit.SECONDS); + check(sent.size()==1,"Duplicate ready reply repeated welcome"); + System.out.println("actualVelocityDispatch=ok syncObserverBeforeAsync=ok playCookie=ok immediateScheduler=ok voiceFalsePositive=none duplicateReply=ignored forwarding=unchanged"); + } finally {blocker.release.countDown();listener.close();workers.shutdownNow();} + } +} diff --git a/velocity/src/test/scripts/verify-welcome-dispatch.sh b/velocity/src/test/scripts/verify-welcome-dispatch.sh new file mode 100755 index 0000000..36a0f0f --- /dev/null +++ b/velocity/src/test/scripts/verify-welcome-dispatch.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# != 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +plugin_jar=$(realpath "$1") +velocity_jar=$(realpath "$2") +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +probe_dir=$(mktemp -d /tmp/grounds-welcome-dispatch.XXXXXX) +trap 'rm -rf -- "$probe_dir"' EXIT +"${JAVA_HOME:+$JAVA_HOME/bin/}javac" -cp "$plugin_jar:$velocity_jar" -d "$probe_dir" "$script_dir/WelcomeDispatchProbe.java" +(cd -- "$probe_dir" && "${JAVA_HOME:+$JAVA_HOME/bin/}java" --enable-native-access=ALL-UNNAMED -cp "$probe_dir:$plugin_jar:$velocity_jar" WelcomeDispatchProbe) From 54568771c0ed1067c0782e39cef7a30575b979aa Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 22:24:05 +0100 Subject: [PATCH 07/13] feat: add support-friendly whereami command --- README.md | 9 + docs/superpowers/plans/2026-11-04-whereami.md | 43 +++++ .../specs/2026-11-04-whereami-design.md | 30 +++ .../proxy/velocity/GroundsProxyPlugin.kt | 11 ++ .../proxy/velocity/ProxySystemMessage.kt | 7 + .../proxy/velocity/command/WhereAmICommand.kt | 79 ++++++++ .../gg/grounds/proxy/messages.properties | 7 + .../gg/grounds/proxy/messages_de.properties | 7 + .../gg/grounds/proxy/messages_es.properties | 7 + .../gg/grounds/proxy/messages_fr.properties | 7 + .../velocity/command/WhereAmICommandTest.kt | 178 ++++++++++++++++++ 11 files changed, 385 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-04-whereami.md create mode 100644 docs/superpowers/specs/2026-11-04-whereami-design.md create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt diff --git a/README.md b/README.md index c512a00..5c05759 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,15 @@ allowed can still _show_ the MOTD; `/motd set` then reports the refusal instead ## Tab footer and diagnostics +`/whereami` is available to every player and shows their current region, full proxy +ID and current backend ID in one indented Grounds chat block. A short explanation +asks players to use these details when contacting support. Hover and click the +proxy or server ID to copy it. Missing values appear as `—`. The command reads the +current connection on whichever proxy handles it, so backend switches appear +immediately without a presence-service lookup. Region uses `REGION`, falling back +to the existing `CONTINENT`/region code when the datacentre is not configured. +All text is localized in English, German, French and Spanish. + The footer is one localized status line, for example `Lobby 2 • 42 players online • 17 ms`. The server number is its deterministic position among the currently registered servers of the same kind, not a pod identifier. All proxies with the same discovery inventory show the same diff --git a/docs/superpowers/plans/2026-11-04-whereami.md b/docs/superpowers/plans/2026-11-04-whereami.md new file mode 100644 index 0000000..e43d1dc --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-whereami.md @@ -0,0 +1,43 @@ +# Whereami Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans for this small change. + +**Goal:** Add the approved support-oriented `/whereami` chat block. + +**Architecture:** A Velocity SimpleCommand renders a single localized component +through the existing system renderer. The command uses the current invoker's +backend and proxy-specific environment data; no external lookup is needed. + +**Tech Stack:** Kotlin, Velocity, Adventure, library-i18n, JUnit, Gradle. + +**Spec:** `docs/superpowers/specs/2026-11-04-whereami-design.md` + +## Constraints and review focus + +No permission grant, no local-proxy assumption about other players, no cached +backend, no duplicate system prefix. Console has no location. Missing data must +not become a copy action. Preserve full IDs and treat all values as literal text. +Use existing colors and pack readiness behavior in all four supported locales. + +## Task 1: Command and presentation + +- [x] Update `velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt` + to assert the single indented block, independent proxy identities, backend + switches, unavailable fields, localized console handling, literal IDs and copy + payloads. Run the focused tests and observe failure for the missing command. +- [x] Add `WhereAmICommand.kt` beside the existing proxy commands; add message keys + in `ProxySystemMessage.kt` and all four `messages*.properties` bundles. +- [x] Register `whereami` in `GroundsProxyPlugin.kt` using this proxy's environment + and document player-facing behavior in `README.md`. +- [x] Run focused tests, then `./gradlew test check shadowJar --offline --console=plain`. + Expected: successful checks and packaged Velocity plugin. +- [x] Review the final diff and record actual verification results. + +## Verification + +The six new command tests failed against the empty command, then passed after +implementation. The final `test check shadowJar --offline --console=plain` run +succeeded, including format checks and packaging. Final review checked one-prefix +rendering, literal values, exact copy payloads, per-proxy identity, current backend +reads and missing data. The command was not exercised on live Minecraft clients. +Unrelated concurrent welcome changes in the checkout were preserved. diff --git a/docs/superpowers/specs/2026-11-04-whereami-design.md b/docs/superpowers/specs/2026-11-04-whereami-design.md new file mode 100644 index 0000000..16b984c --- /dev/null +++ b/docs/superpowers/specs/2026-11-04-whereami-design.md @@ -0,0 +1,30 @@ +# Whereami + +Players can use `/whereami` without a permission grant to identify their current +connection for support. The agreed presentation is one Grounds system message: + +```text +[Grounds] Deine aktuelle Verbindung im Netzwerk. + Diese Angaben helfen dem Support, Probleme einzugrenzen. + + Region: nl-ams1 + Proxy: velocity-eu-1 + Server: lobby-2 +``` + +Use the existing recipient-aware system renderer and palette. The hint and labels +are muted. Proxy and server values are interactive accent text: hover explains +that clicking copies the full ID. Retain complete IDs. Region is ordinary body +text. Missing values render as a noninteractive em dash. Localize all prose in +English, German, French and Spanish, including the player-only console response. + +The user chose the indented block over a compact single-line summary. Send the +block once so the system prefix is not repeated on every row. No separate UI, +network request or location cache is needed for a command about its invoker. +Each proxy supplies its own `REGION` (falling back to the existing region code) +and `PROXY_ID`; read the invoker's current backend at execution time. This works +independently on every proxy and reflects backend switches immediately. + +Implementation choices: colon-separated labels avoid fragile table alignment in +Minecraft's proportional font; no ID abbreviation is required. Keep existing +`/proxydebug` behavior unchanged. 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 1afd57f..7fcf802 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -38,6 +38,7 @@ import gg.grounds.proxy.velocity.command.OnlineCommand import gg.grounds.proxy.velocity.command.ProxyCommand import gg.grounds.proxy.velocity.command.ProxyDebugCommand import gg.grounds.proxy.velocity.command.RegionCommand +import gg.grounds.proxy.velocity.command.WhereAmICommand import gg.grounds.proxy.velocity.handler.NatsHandler import gg.grounds.proxy.velocity.listener.LobbyCountsListener import gg.grounds.proxy.velocity.listener.ProxyHopListener @@ -410,6 +411,16 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { proxy.commandManager.register("proxydebug", ProxyDebugCommand(messages)) val proxyId = System.getenv("PROXY_ID").orEmpty() + proxy.commandManager.register( + "whereami", + WhereAmICommand( + currentRegion = { + System.getenv("REGION")?.trim()?.takeIf(String::isNotEmpty) ?: region() + }, + currentProxy = { proxyId }, + messages = messages, + ), + ) val hops = ProxyHopListener( this, diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt index 508fd25..f3f68f4 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt @@ -14,6 +14,13 @@ enum class ProxySystemMessage( DEBUG_DENIED("debug.denied", level = SystemLevel.ERROR), DEBUG_SERVER("debug.server"), DEBUG_PROXY("debug.proxy"), + WHEREAMI_PLAYER_ONLY("whereami.player-only", level = SystemLevel.ERROR), + WHEREAMI_HEADER("whereami.header"), + WHEREAMI_SUPPORT("whereami.support"), + WHEREAMI_REGION("whereami.region"), + WHEREAMI_PROXY("whereami.proxy"), + WHEREAMI_SERVER("whereami.server"), + WHEREAMI_COPY_HINT("whereami.copy-hint"), ONLINE_UNAVAILABLE("online.unavailable", level = SystemLevel.ERROR), ONLINE_HEADER("online.header"), ONLINE_NOBODY("online.nobody"), diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt new file mode 100644 index 0000000..2e61538 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt @@ -0,0 +1,79 @@ +package gg.grounds.proxy.velocity.command + +import com.velocitypowered.api.command.SimpleCommand +import com.velocitypowered.api.proxy.Player +import gg.grounds.i18n.Palette +import gg.grounds.i18n.Translations +import gg.grounds.proxy.velocity.ProxySystemMessage +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent + +/** The invoker's live connection, rendered as one support-friendly system message. */ +class WhereAmICommand( + private val currentRegion: () -> String?, + private val currentProxy: () -> String?, + private val messages: Translations, +) : SimpleCommand { + override fun execute(invocation: SimpleCommand.Invocation) { + val source = invocation.source() + val player = source as? Player + if (player == null) { + messages.send(source, ProxySystemMessage.WHEREAMI_PLAYER_ONLY) + return + } + + // The invoker is connected here. Read their backend directly so a server switch is + // reflected immediately, without waiting for network presence heartbeats. + val region = currentRegion() + val proxyId = currentProxy() + val server = player.currentServer.orElse(null)?.serverInfo?.name + val block = + Component.empty() + .append(messages.render(ProxySystemMessage.WHEREAMI_HEADER, player)) + .append(Component.newline()) + .append(messages.render(ProxySystemMessage.WHEREAMI_SUPPORT, player)) + .append(Component.newline()) + .append(Component.newline()) + .append( + messages.render( + ProxySystemMessage.WHEREAMI_REGION, + player, + "region" to value(player, region), + ) + ) + .append(Component.newline()) + .append( + messages.render( + ProxySystemMessage.WHEREAMI_PROXY, + player, + "proxy" to value(player, proxyId, copyable = true), + ) + ) + .append(Component.newline()) + .append( + messages.render( + ProxySystemMessage.WHEREAMI_SERVER, + player, + "server" to value(player, server, copyable = true), + ) + ) + + messages.sendRendered(player, ProxySystemMessage.WHEREAMI_HEADER, block) + } + + private fun value(player: Player, raw: String?, copyable: Boolean = false): Component { + val value = + raw?.trim()?.takeIf(String::isNotEmpty) + ?: return Component.text("—", Palette.TEXT_MUTED) + val text = Component.text(value, if (copyable) Palette.ACCENT else Palette.TEXT) + if (!copyable) return text + return text + .clickEvent(ClickEvent.copyToClipboard(value)) + .hoverEvent( + HoverEvent.showText( + messages.render(ProxySystemMessage.WHEREAMI_COPY_HINT, player, "value" to value) + ) + ) + } +} diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index f26f8dc..03249bf 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -11,6 +11,13 @@ online.nobody=nobody is connected online.region= — online.region.unknown=unknown region — online.proxy=: +whereami.player-only=This command is only available in-game. +whereami.header=Your current connection on the network. +whereami.support= These details help support investigate problems. +whereami.region= Region: +whereami.proxy= Proxy: +whereami.server= Server: +whereami.copy-hint=Click to copy region.player-only=Only a player can change region. region.none=No regions are configured on this proxy. region.unknown=Unknown region ''. Available: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index eb7b823..85be26d 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -9,6 +9,13 @@ online.nobody=niemand ist verbunden online.region= — online.region.unknown=unbekannte Region — online.proxy=: +whereami.player-only=Diesen Befehl kannst du nur im Spiel nutzen. +whereami.header=Deine aktuelle Verbindung im Netzwerk. +whereami.support= Diese Angaben helfen dem Support, Probleme einzugrenzen. +whereami.region= Region: +whereami.proxy= Proxy: +whereami.server= Server: +whereami.copy-hint=Klicken zum Kopieren region.player-only=Nur Spieler können die Region wechseln. region.none=Auf diesem Proxy sind keine Regionen konfiguriert. region.unknown=Unbekannte Region ''. Verfügbar: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 2c17fb5..f8c3b61 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -9,6 +9,13 @@ online.nobody=no hay nadie conectado online.region= — online.region.unknown=región desconocida — online.proxy=: +whereami.player-only=Este comando solo está disponible dentro del juego. +whereami.header=Tu conexión actual en la red. +whereami.support= Estos datos ayudan al soporte a identificar problemas. +whereami.region= Región: +whereami.proxy= Proxy: +whereami.server= Servidor: +whereami.copy-hint=Haz clic para copiar region.player-only=Solo un jugador puede cambiar de región. region.none=No hay regiones configuradas en este proxy. region.unknown=Región desconocida ''. Disponibles: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 987b952..20361e2 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -9,6 +9,13 @@ online.nobody=personne n'est connecté online.region= — online.region.unknown=région inconnue — online.proxy=: +whereami.player-only=Cette commande est disponible uniquement en jeu. +whereami.header=Votre connexion actuelle au réseau. +whereami.support= Ces informations aident le support à identifier les problèmes. +whereami.region= Région : +whereami.proxy= Proxy : +whereami.server= Serveur : +whereami.copy-hint=Cliquez pour copier region.player-only=Seul un joueur peut changer de région. region.none=Aucune région n'est configurée sur ce proxy. region.unknown=Région inconnue ''. Disponibles : diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt new file mode 100644 index 0000000..fb36b9a --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt @@ -0,0 +1,178 @@ +package gg.grounds.proxy.velocity.command + +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.command.SimpleCommand +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.server.ServerInfo +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages +import java.lang.reflect.Proxy +import java.net.InetSocketAddress +import java.util.Locale +import java.util.Optional +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WhereAmICommandTest { + private val messages = + Translations.forBundleWithSystemRenderer( + "gg.grounds.proxy.messages", + javaClass.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ) + private val received = mutableListOf() + private var server: String? = "lobby-1" + + @Test + fun `each proxy reports its own region and proxy without a permission grant`() { + for ((region, proxyId) in + listOf("nl-ams1" to "velocity-eu-1", "us-nyc1" to "velocity-na-2")) { + received.clear() + val command = WhereAmICommand({ region }, { proxyId }, messages) + val invocation = invocation(player()) + + assertTrue(command.hasPermission(invocation)) + command.execute(invocation) + + assertEquals(1, received.size) + assertTrue(plain().contains("Region: $region"), plain()) + assertTrue(plain().contains("Proxy: $proxyId"), plain()) + assertTrue(plain().contains("Server: lobby-1"), plain()) + } + } + + @Test + fun `support block has one prefix and indented rows with copyable full IDs`() { + val proxyId = "velocity-eu-5fbc57c96d-r8pf9" + server = "minestom-lobby-7c77bc4b6f-9zpht" + WhereAmICommand({ "nl-ams1" }, { proxyId }, messages) + .execute(invocation(player(Locale.GERMAN))) + + val lines = plain().lines() + assertEquals(6, lines.size) + assertTrue(lines[0].contains("Deine aktuelle Verbindung im Netzwerk."), plain()) + assertTrue(lines[1].startsWith(" "), plain()) + assertTrue(lines[1].contains("Support"), plain()) + assertEquals("", lines[2]) + assertEquals(" Region: nl-ams1", lines[3]) + assertEquals(" Proxy: $proxyId", lines[4]) + assertEquals(" Server: $server", lines[5]) + val copies = nodes(received.single()).filter { it.clickEvent() != null } + assertEquals(2, copies.size) + assertEquals(ClickEvent.copyToClipboard(proxyId), copies[0].clickEvent()) + assertEquals(ClickEvent.copyToClipboard(server!!), copies[1].clickEvent()) + copies.forEach { node -> + val hint = node.hoverEvent()!!.value() as Component + assertTrue( + PlainTextComponentSerializer.plainText() + .serialize(hint) + .contains("kopieren", ignoreCase = true) + ) + } + } + + @Test + fun `a backend switch is reflected on the next invocation`() { + val command = WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages) + val invocation = invocation(player()) + command.execute(invocation) + received.clear() + + server = "bedwars-9" + command.execute(invocation) + + assertTrue(plain().contains("Server: bedwars-9"), plain()) + assertFalse(plain().contains("lobby-1"), plain()) + } + + @Test + fun `missing region proxy and backend render as unknown`() { + server = null + WhereAmICommand({ null }, { " " }, messages).execute(invocation(player())) + + assertTrue(plain().contains("Region: —"), plain()) + assertTrue(plain().contains("Proxy: —"), plain()) + assertTrue(plain().contains("Server: —"), plain()) + assertTrue(nodes(received.single()).all { it.clickEvent() == null }) + } + + @Test + fun `console gets a localized player-only message`() { + val console = source(CommandSource::class.java, Locale.GERMAN) + WhereAmICommand({ error("Console has no location") }, { "proxy" }, messages) + .execute(invocation(console)) + + assertEquals(1, received.size) + assertTrue(plain().contains("nur im Spiel"), plain()) + } + + @Test + fun `location values render safely in every supported locale`() { + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + received.clear() + WhereAmICommand({ "nl-ams1" }, { "proxy" }, messages) + .execute(invocation(player(locale))) + + assertEquals(1, received.size) + assertTrue(plain().contains("nl-ams1"), plain()) + assertTrue(plain().contains("proxy"), plain()) + assertTrue(plain().contains("lobby-1"), plain()) + assertFalse(plain().contains("whereami."), plain()) + } + } + + private fun nodes(component: Component): List = + listOf(component) + component.children().flatMap(::nodes) + + private fun plain() = + received.joinToString("\n") { PlainTextComponentSerializer.plainText().serialize(it) } + + private fun player(locale: Locale = Locale.ENGLISH): Player = source(Player::class.java, locale) + + private fun source(type: Class, locale: Locale): T = + fake(type) { name, args -> + when (name) { + "getCurrentServer" -> + Optional.ofNullable( + server?.let { name -> + fake(ServerConnection::class.java) { method, _ -> + if (method == "getServerInfo") { + ServerInfo(name, InetSocketAddress("127.0.0.1", 25565)) + } else error("Unexpected server method: $method") + } + } + ) + "get" -> Optional.of(locale) + "hasPermission" -> false + "sendMessage" -> { + received += args!!.filterIsInstance().single() + null + } + else -> error("Unexpected source method: $name") + } + } + + private fun invocation(source: CommandSource): SimpleCommand.Invocation = + fake(SimpleCommand.Invocation::class.java) { name, _ -> + when (name) { + "source" -> source + "arguments" -> emptyArray() + "alias" -> "whereami" + else -> error("Unexpected invocation method: $name") + } + } + + private fun fake(type: Class, invoke: (String, Array?) -> Any?): T = + type.cast( + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(type)) { _, method, args -> + invoke(method.name, args) + } + ) +} From 7fdc8fd9612795e6fdc8f1ad58aefb66d6b3da70 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 22:26:22 +0100 Subject: [PATCH 08/13] feat: use the floating-stone logo and bossbar bullet in welcome --- README.md | 2 + .../specs/2026-11-04-join-welcome-design.md | 2 + .../proxy/velocity/GroundsProxyPlugin.kt | 6 ++- .../proxy/velocity/welcome/WelcomeGlyphs.kt | 26 ++++++++----- .../proxy/velocity/welcome/WelcomeLayout.kt | 5 ++- .../proxy/velocity/welcome/WelcomeMessage.kt | 26 ++++++++++--- .../gg/grounds/proxy/messages.properties | 2 +- .../gg/grounds/proxy/messages_de.properties | 2 +- .../gg/grounds/proxy/messages_es.properties | 2 +- .../gg/grounds/proxy/messages_fr.properties | 2 +- .../velocity/welcome/WelcomeMessageTest.kt | 38 ++++++++++++++++++- 11 files changed, 90 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c512a00..78ad421 100644 --- a/README.md +++ b/README.md @@ -264,3 +264,5 @@ maintenance is active. New maintenance drafts default to an empty override. Published on tag as `gg.grounds:plugin-proxy-api` (consume this) and `plugin-proxy-velocity`, and as `ghcr.io/groundsgg/plugin-proxy` — the image carries the shaded JAR at `/jar/plugin.jar` for the `plugin-velocity-jar` Helm chart. Welcome bitmap icons use `grounds:welcome` U+E400/U+E401/U+E402. Set `WELCOME_FONT_PACK_SHA1S` to the verified platform-pack hashes containing these glyphs and restrict `HUD_FONT_PACK_SHA1S` to that same set. Icons remain disabled unless every HUD-ready hash is covered; unknown packs show plain labels. Existing chat, sidebar and HUD pack allowlists must include a newly activated platform pack as appropriate. + +The welcome can use the original full Grounds logo with floating stones. Only enable WELCOME_LOGO_PACK_SHA1S for HUD-approved hashes containing U+E403; otherwise the existing compact wordmark remains. Its40px artwork reserves more rows; with Voice help the block ends in normal-height text and still fits ten closed-chat rows. Voice copy and Install use the same sand-coloured bullet as the bossbar. diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md index eb4648f..1520eb5 100644 --- a/docs/superpowers/specs/2026-11-04-join-welcome-design.md +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -21,3 +21,5 @@ Centered Mini-CD follow-up: center every visible line within the default 320 GUI Chat-background spacing correction: the user's actual client screenshot shows social bitmaps extending below the final chat background when the mod is installed. Always append a blank footer row. With verified wordmark and no voice help, reserve three leading rows for extra top margin. With voice help, retain two leading rows and combine the short localized voice description with a separate cyan installation link on one centered row so the full welcome still fits ten closed-chat rows including footer. Social icons retain an empty row above and below. Shorten the greeting to “Willkommen, !” and equivalents because the wordmark already names Grounds; highlight the name gold. A generic welcome is appropriate for both first-time and returning players and does not imply new player-history detection. Immediate-delivery follow-up: remove the fixed five-second modern-client delay. Required packs already complete in configuration. After the first backend joins and transfer dedupe is resolved false, request a separate `grounds:welcome_ready` cookie and schedule welcome without a delay on its reply. This costs a single network round trip and does not conflate an earlier Login-cookie response with Play readiness. Preserve exact connection identity, disconnect cancellation and duplicate guards. Unsolicited/stale readiness replies do not schedule anything. Protocols before1.20.5 retain the old five-second grace. Voice installation observers run at maximum priority with a synchronous memory capture, then always return EventTask.async so later handlers preserve their normal async execution. The actual deployed Velocity dispatcher is covered by a retained standalone probe that blocks a following async handler while delivering the cookie response. Native26.3 cookie handling runs on the client main thread; SVC's Fabric early-join callback sends its secret request from JoinWorld. Full localized Voice sentences now end in a period followed by a normal space and an independent installation link; native measured widths EN282/DE308/FR301/ES314px, all below320. + +Floating-stones follow-up: use the original full logo in a separate grounds:welcome U+E403 glyph at40px height/ascent33 with86px advance. Reserve four leading rows without Voice help and three with it; the complete block remains ten rows. The normal text ending in the Voice variant needs no extra footer; the bitmap-ending variant retains its footer. The original tab/sidebar assets remain unchanged. Gate new art with WELCOME_LOGO_PACK_SHA1S covering all allowed HUD hashes. Replace the punctuation/space before Install with the same sand-coloured “ • ” separator as the bossbar; localized copy omits the terminal period and Spanish is shortened to remain under320px. 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 1afd57f..5271d99 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -317,7 +317,11 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { val welcomeListener = WelcomeListener( messages, - WelcomeMessage(messages, WelcomeLinks.from()), + WelcomeMessage( + messages, + WelcomeLinks.from(), + decoratedLogoEnabled = WelcomeGlyphs.decoratedLogoEnabled(System.getenv()), + ), schedule = { task -> proxy.scheduler.buildTask(this, task).schedule() }, logoReady = { player -> ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt index a9168ca..75745bc 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt @@ -7,19 +7,25 @@ internal object WelcomeGlyphs { const val DISCORD = '\uE400' const val TIKTOK = '\uE401' const val SHOP = '\uE402' + const val LOGO = '\uE403' + const val LOGO_ADVANCE = 86 // 512x241 bitmap at height40, rounded advance plus1px glyph gap. /** HUD readiness is sufficient only if every approved HUD pack contains these glyphs. */ fun supportedBy(hudPacks: Set, welcomePacks: Set): Boolean = hudPacks.isNotEmpty() && hudPacks.all { it in welcomePacks } - fun enabled(environment: Map): Boolean { - fun hashes(name: String) = - environment[name] - .orEmpty() - .split(',') - .map(String::trim) - .filter(String::isNotEmpty) - .toSet() - return supportedBy(hashes("HUD_FONT_PACK_SHA1S"), hashes("WELCOME_FONT_PACK_SHA1S")) - } + private fun hashes(environment: Map, name: String): Set = + environment[name].orEmpty().split(',').map(String::trim).filter(String::isNotEmpty).toSet() + + fun enabled(environment: Map): Boolean = + supportedBy( + hashes(environment, "HUD_FONT_PACK_SHA1S"), + hashes(environment, "WELCOME_FONT_PACK_SHA1S"), + ) + + fun decoratedLogoEnabled(environment: Map): Boolean = + supportedBy( + hashes(environment, "HUD_FONT_PACK_SHA1S"), + hashes(environment, "WELCOME_LOGO_PACK_SHA1S"), + ) } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt index 90cef1e..6850bbf 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt @@ -38,6 +38,8 @@ internal object WelcomeLayout { (component as? TextComponent)?.content()?.sumOf { ch -> when { font == TabGlyphs.FONT && ch == TabGlyphs.LOGO -> TabGlyphs.LOGO_ADVANCE + font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.LOGO -> + WelcomeGlyphs.LOGO_ADVANCE font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.SHOP -> 13 font == WelcomeGlyphs.FONT && ch in '\uE400'..'\uE401' -> 14 else -> advance(ch) + if (bold && ch != ' ') 1 else 0 @@ -62,7 +64,8 @@ internal object WelcomeLayout { '·' -> 2 '`', 'l', - 'í' -> 3 + 'í', + '•' -> 3 '"', '(', ')', diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index d2b4fa6..c85c169 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -28,9 +28,12 @@ internal data class WelcomeLinks( } /** One branded block: every link owns its click event, so it cannot leak onto the next line. */ -internal class WelcomeMessage( +internal class WelcomeMessage +@JvmOverloads +constructor( private val messages: Translations, private val links: WelcomeLinks = WelcomeLinks(), + private val decoratedLogoEnabled: Boolean = false, ) { private enum class Copy(override val id: String) : MessageKey { GREETING("welcome.greeting"), @@ -46,8 +49,13 @@ internal class WelcomeMessage( voiceInstalled: Boolean, iconsReady: Boolean = false, ): Component { + val decoratedLogo = decoratedLogoEnabled && logoReady && iconsReady val brand = - if (logoReady) + if (decoratedLogo) + Component.text(WelcomeGlyphs.LOGO.toString()) + .font(WelcomeGlyphs.FONT) + .color(TextColor.color(0xFFFFFF)) + else if (logoReady) Component.text(TabGlyphs.LOGO.toString()) .font(TabGlyphs.FONT) .color(TextColor.color(0xFFFFFF)) @@ -55,7 +63,12 @@ internal class WelcomeMessage( val result = Component.text().font(Key.key("minecraft:default")) // The tall wordmark needs two reserved rows; use a third when voice help is absent. // Together with the footer this keeps every bitmap inside the chat background. - val leadingRows = if (logoReady) (if (voiceInstalled) 3 else 2) else 1 + val leadingRows = + when { + decoratedLogo -> if (voiceInstalled) 4 else 3 + logoReady -> if (voiceInstalled) 3 else 2 + else -> 1 + } repeat(leadingRows) { result.appendNewline() } result.append(WelcomeLayout.center(brand, logoReady)).appendNewline() if (logoReady) result.appendNewline() @@ -100,7 +113,7 @@ internal class WelcomeMessage( WelcomeLayout.center( Component.empty() .append(messages.render(Copy.VOICE, locale)) - .append(Component.space()) + .append(Component.text(" • ", SAND)) .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)), logoReady, ) @@ -108,7 +121,10 @@ internal class WelcomeMessage( } // Bitmap icons extend below a normal text row, so never end the background at their // baseline. - return result.appendNewline().build() + // The taller decorated logo uses the full ten rows with voice help. That variant ends in + // normal-height text; the icon row already has a blank row below it. + return if (decoratedLogo && !voiceInstalled) result.build() + else result.appendNewline().build() } private fun communityLink( diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index f26f8dc..942dd5e 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -79,5 +79,5 @@ proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. welcome.greeting=Welcome, <#E7B84B>! -welcome.voice=<#C1B9AA>We support party, game and proximity voice chat. +welcome.voice=<#C1B9AA>We support party, game and proximity voice chat welcome.install=<#67D9EC>Install diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index eb7b823..5cb545e 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -78,5 +78,5 @@ proxy.entry.current= (): (du bist hier)Klicke auf einen Proxy, um zu wechseln. # Welcome block welcome.greeting=Willkommen, <#E7B84B>! -welcome.voice=<#C1B9AA>Wir bieten Voice Chat in Partys, Spielen und lokal. +welcome.voice=<#C1B9AA>Wir bieten Voice Chat in Partys, Spielen und lokal welcome.install=<#67D9EC>Installieren diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 2c17fb5..e625a07 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -78,5 +78,5 @@ proxy.entry.current= (): (estás aquí)Haz clic en un proxy para cambiar. # Welcome block welcome.greeting=¡Bienvenido, <#E7B84B>! -welcome.voice=<#C1B9AA>Hay chat de voz en grupos, partidas y por cercanía. +welcome.voice=<#C1B9AA>Hay chat de voz grupal, en partida y por cercanía welcome.install=<#67D9EC>Instalar diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 987b952..bc9330d 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -78,5 +78,5 @@ proxy.entry.current= () : (vous êtes ici)Cliquez sur un proxy pour changer. # Welcome block welcome.greeting=Bienvenue, <#E7B84B> ! -welcome.voice=<#C1B9AA>Le vocal marche en groupe, en jeu et à proximité. +welcome.voice=<#C1B9AA>Le vocal marche en groupe, en jeu et à proximité welcome.install=<#67D9EC>Installer diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index b1e0f8b..2f2330c 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -26,8 +26,8 @@ class WelcomeMessageTest { fun `each welcome line is inset and shares the same visual center`() { val lines = plain(renderer.render("Alex", Locale.GERMAN, true, false, true)).lines() // Widths measured from the native Minecraft font and the active bitmap pack: - // wordmark 129, shorter greeting 78, social row 169, full-sentence voice row 308px. - val expectedInsets = mapOf(2 to 95, 4 to 121, 6 to 75, 8 to 6) + // wordmark 129, shorter greeting 78, social row 169, voice row with bossbar bullet 313px. + val expectedInsets = mapOf(2 to 95, 4 to 121, 6 to 75, 8 to 3) for ((row, expected) in expectedInsets) { val prefix = lines[row].takeWhile { it in '\uE010'..'\uE025' } val inset = prefix.sumOf { 1 shl ((it.code - 0xE010) / 2) } @@ -63,6 +63,40 @@ class WelcomeMessageTest { assertFalse(greeting.contains("Grounds"), "The wordmark already names Grounds") } + @Test + fun `decorated logo fits with floating stones inside the chat background`() { + val decorated = + WelcomeMessage( + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), + decoratedLogoEnabled = true, + ) + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + for (installed in listOf(false, true)) { + val message = decorated.render("Alex", locale, true, installed, true) + val lines = plain(message).lines() + assertEquals(10, lines.size) + assertTrue(lines[if (installed) 4 else 3].contains('\uE403')) + assertTrue( + nodes(message).any { + it.font() == Key.key("grounds:welcome") && plain(it).contains('\uE403') + } + ) + if (installed) assertTrue(lines.last().isEmpty()) + else assertTrue(plain(message).contains(" • ")) + } + } + assertFalse( + plain(decorated.render("Alex", Locale.GERMAN, true, true, false)).contains('\uE403') + ) + assertFalse(WelcomeGlyphs.decoratedLogoEnabled(mapOf("HUD_FONT_PACK_SHA1S" to "old"))) + assertTrue( + WelcomeGlyphs.decoratedLogoEnabled( + mapOf("HUD_FONT_PACK_SHA1S" to "new", "WELCOME_LOGO_PACK_SHA1S" to "new") + ) + ) + } + @Test fun `links below the brand have independent click targets`() { val message = renderer.render("Alex", Locale.ENGLISH, true, true, true) From f14c9e28e77b8dfcb980390f226aea128a7e3712 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 22:48:04 +0100 Subject: [PATCH 09/13] feat: refine whereami with Mini-CD pixel icons --- README.md | 5 ++ .../specs/2026-11-04-whereami-design.md | 23 +++++-- .../proxy/velocity/GroundsProxyPlugin.kt | 8 +++ .../proxy/velocity/command/WhereAmICommand.kt | 65 ++++++++++++------- .../proxy/velocity/command/WhereAmIGlyphs.kt | 33 ++++++++++ .../gg/grounds/proxy/messages.properties | 10 +-- .../gg/grounds/proxy/messages_de.properties | 10 +-- .../gg/grounds/proxy/messages_es.properties | 10 +-- .../gg/grounds/proxy/messages_fr.properties | 10 +-- .../velocity/command/WhereAmICommandTest.kt | 59 +++++++++++++++-- 10 files changed, 182 insertions(+), 51 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt diff --git a/README.md b/README.md index 5c05759..ef970a2 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,11 @@ current connection on whichever proxy handles it, so backend switches appear immediately without a presence-service lookup. Region uses `REGION`, falling back to the existing `CONTINENT`/region code when the datacentre is not configured. All text is localized in English, German, French and Spanish. +The heading and body follow the Minecraft Mini-CD (warm white, sand and cyan +copy actions). Three first-party pixel icons in `grounds:connection` appear only +with verified HUD readiness and a compatible `WHEREAMI_FONT_PACK_SHA1S` allowlist; +otherwise the indented text labels remain. The icons use an additive platform +pack font and do not replace vanilla fonts or the existing welcome graphics. The footer is one localized status line, for example `Lobby 2 • 42 players online • 17 ms`. The server number is its deterministic position among the currently registered servers of the diff --git a/docs/superpowers/specs/2026-11-04-whereami-design.md b/docs/superpowers/specs/2026-11-04-whereami-design.md index 16b984c..9f97419 100644 --- a/docs/superpowers/specs/2026-11-04-whereami-design.md +++ b/docs/superpowers/specs/2026-11-04-whereami-design.md @@ -4,12 +4,12 @@ Players can use `/whereami` without a permission grant to identify their current connection for support. The agreed presentation is one Grounds system message: ```text -[Grounds] Deine aktuelle Verbindung im Netzwerk. +[Grounds] Allgemein | Dein Standort im Netzwerk Diese Angaben helfen dem Support, Probleme einzugrenzen. - Region: nl-ams1 - Proxy: velocity-eu-1 - Server: lobby-2 + [Pin] Region: nl-ams1 + [Knoten] Proxy: velocity-eu-1 + [Server] Server: lobby-2 ``` Use the existing recipient-aware system renderer and palette. The hint and labels @@ -28,3 +28,18 @@ independently on every proxy and reflects backend switches immediately. Implementation choices: colon-separated labels avoid fragile table alignment in Minecraft's proportional font; no ID abbreviation is required. Keep existing `/proxydebug` behavior unchanged. + + +Refinement: use the actual Minecraft Mini-CD values, not the web palette: +primary #F3F0EA, secondary #C1B9AA, interactive #67D9EC. The title is bold. +Three first-party, hard-alpha 10px pixel icons (pin, connected nodes, server rack) +use gold #E7B84B, copper #DDA16F, warm white and cyan. Their own atlas is +`grounds:connection`, U+E410–U+E412, rendered white to preserve texture colors. +One transparent top/bottom margin keeps the visible ink inside the native chat +row. Existing support copy, full IDs and independent copy actions remain. + +Icons require both recipient-specific verified HUD readiness and an environment +allowlist proving every approved HUD pack contains the new glyphs. Otherwise +omit icons and preserve the text labels, including on Bedrock. No unverified PUA +characters or assumed client glyphs are sent. A separate immutable Stage pack pin +adds only the new font and atlas; existing pack content and global channels remain. 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 7fcf802..962a3aa 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -39,6 +39,7 @@ import gg.grounds.proxy.velocity.command.ProxyCommand import gg.grounds.proxy.velocity.command.ProxyDebugCommand import gg.grounds.proxy.velocity.command.RegionCommand import gg.grounds.proxy.velocity.command.WhereAmICommand +import gg.grounds.proxy.velocity.command.WhereAmIGlyphs import gg.grounds.proxy.velocity.handler.NatsHandler import gg.grounds.proxy.velocity.listener.LobbyCountsListener import gg.grounds.proxy.velocity.listener.ProxyHopListener @@ -411,6 +412,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { proxy.commandManager.register("proxydebug", ProxyDebugCommand(messages)) val proxyId = System.getenv("PROXY_ID").orEmpty() + val whereamiIconsEnabled = WhereAmIGlyphs.enabled(System.getenv()) proxy.commandManager.register( "whereami", WhereAmICommand( @@ -419,6 +421,12 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { }, currentProxy = { proxyId }, messages = messages, + iconsReady = { player -> + whereamiIconsEnabled && + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.ready == true + }, ), ) val hops = diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt index 2e61538..48cb93b 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt @@ -2,18 +2,21 @@ package gg.grounds.proxy.velocity.command import com.velocitypowered.api.command.SimpleCommand import com.velocitypowered.api.proxy.Player -import gg.grounds.i18n.Palette import gg.grounds.i18n.Translations import gg.grounds.proxy.velocity.ProxySystemMessage import net.kyori.adventure.text.Component import net.kyori.adventure.text.event.ClickEvent import net.kyori.adventure.text.event.HoverEvent +import net.kyori.adventure.text.format.TextColor /** The invoker's live connection, rendered as one support-friendly system message. */ -class WhereAmICommand( +class WhereAmICommand +@JvmOverloads +constructor( private val currentRegion: () -> String?, private val currentProxy: () -> String?, private val messages: Translations, + private val iconsReady: (Player) -> Boolean = { false }, ) : SimpleCommand { override fun execute(invocation: SimpleCommand.Invocation) { val source = invocation.source() @@ -25,6 +28,7 @@ class WhereAmICommand( // The invoker is connected here. Read their backend directly so a server switch is // reflected immediately, without waiting for network presence heartbeats. + val showIcons = iconsReady(player) val region = currentRegion() val proxyId = currentProxy() val server = player.currentServer.orElse(null)?.serverInfo?.name @@ -32,41 +36,50 @@ class WhereAmICommand( Component.empty() .append(messages.render(ProxySystemMessage.WHEREAMI_HEADER, player)) .append(Component.newline()) - .append(messages.render(ProxySystemMessage.WHEREAMI_SUPPORT, player)) + .append( + messages.render(ProxySystemMessage.WHEREAMI_SUPPORT, player).color(SECONDARY) + ) .append(Component.newline()) .append(Component.newline()) .append( - messages.render( - ProxySystemMessage.WHEREAMI_REGION, - player, - "region" to value(player, region), - ) + messages + .render( + ProxySystemMessage.WHEREAMI_REGION, + player, + "icon" to WhereAmIGlyphs.render(WhereAmIGlyphs.REGION, showIcons), + "region" to value(player, region), + ) + .color(SECONDARY) ) .append(Component.newline()) .append( - messages.render( - ProxySystemMessage.WHEREAMI_PROXY, - player, - "proxy" to value(player, proxyId, copyable = true), - ) + messages + .render( + ProxySystemMessage.WHEREAMI_PROXY, + player, + "icon" to WhereAmIGlyphs.render(WhereAmIGlyphs.PROXY, showIcons), + "proxy" to value(player, proxyId, copyable = true), + ) + .color(SECONDARY) ) .append(Component.newline()) .append( - messages.render( - ProxySystemMessage.WHEREAMI_SERVER, - player, - "server" to value(player, server, copyable = true), - ) + messages + .render( + ProxySystemMessage.WHEREAMI_SERVER, + player, + "icon" to WhereAmIGlyphs.render(WhereAmIGlyphs.SERVER, showIcons), + "server" to value(player, server, copyable = true), + ) + .color(SECONDARY) ) messages.sendRendered(player, ProxySystemMessage.WHEREAMI_HEADER, block) } private fun value(player: Player, raw: String?, copyable: Boolean = false): Component { - val value = - raw?.trim()?.takeIf(String::isNotEmpty) - ?: return Component.text("—", Palette.TEXT_MUTED) - val text = Component.text(value, if (copyable) Palette.ACCENT else Palette.TEXT) + val value = raw?.trim()?.takeIf(String::isNotEmpty) ?: return Component.text("—", SECONDARY) + val text = Component.text(value, if (copyable) INTERACTIVE else PRIMARY) if (!copyable) return text return text .clickEvent(ClickEvent.copyToClipboard(value)) @@ -76,4 +89,12 @@ class WhereAmICommand( ) ) } + + private companion object { + // Minecraft Mini-CD tokens (docs/design/minecraft-mini-cd-and-chat.md). + // The general web palette's muted/mint tokens have different values. + val PRIMARY = TextColor.color(0xF3F0EA) + val SECONDARY = TextColor.color(0xC1B9AA) + val INTERACTIVE = TextColor.color(0x67D9EC) + } } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt new file mode 100644 index 0000000..5a1e2ea --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt @@ -0,0 +1,33 @@ +package gg.grounds.proxy.velocity.command + +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor + +internal object WhereAmIGlyphs { + val FONT = Key.key("grounds:connection") + const val REGION = '\uE410' + const val PROXY = '\uE411' + const val SERVER = '\uE412' + + fun render(glyph: Char, ready: Boolean): Component = + if (ready) + Component.empty() + .append(Component.text(glyph.toString(), NamedTextColor.WHITE).font(FONT)) + .append(Component.space()) + else Component.empty() + + /** Readiness for a HUD pack is proof only when every allowed pack has these new glyphs. */ + fun enabled(environment: Map): Boolean { + fun hashes(name: String) = + environment[name] + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + val hud = hashes("HUD_FONT_PACK_SHA1S") + val connections = hashes("WHEREAMI_FONT_PACK_SHA1S") + return hud.isNotEmpty() && hud.all { it in connections } + } +} diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index 03249bf..40d39af 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -12,11 +12,11 @@ online.region= — online.region.unknown=unknown region — online.proxy=: whereami.player-only=This command is only available in-game. -whereami.header=Your current connection on the network. -whereami.support= These details help support investigate problems. -whereami.region= Region: -whereami.proxy= Proxy: -whereami.server= Server: +whereami.header=Your network location +whereami.support=\ These details help support investigate problems. +whereami.region=\ Region: +whereami.proxy=\ Proxy: +whereami.server=\ Server: whereami.copy-hint=Click to copy region.player-only=Only a player can change region. region.none=No regions are configured on this proxy. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 85be26d..0321397 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -10,11 +10,11 @@ online.region= — online.region.unknown=unbekannte Region — online.proxy=: whereami.player-only=Diesen Befehl kannst du nur im Spiel nutzen. -whereami.header=Deine aktuelle Verbindung im Netzwerk. -whereami.support= Diese Angaben helfen dem Support, Probleme einzugrenzen. -whereami.region= Region: -whereami.proxy= Proxy: -whereami.server= Server: +whereami.header=Dein Standort im Netzwerk +whereami.support=\ Diese Angaben helfen dem Support, Probleme einzugrenzen. +whereami.region=\ Region: +whereami.proxy=\ Proxy: +whereami.server=\ Server: whereami.copy-hint=Klicken zum Kopieren region.player-only=Nur Spieler können die Region wechseln. region.none=Auf diesem Proxy sind keine Regionen konfiguriert. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index f8c3b61..0707680 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -10,11 +10,11 @@ online.region= — online.region.unknown=región desconocida — online.proxy=: whereami.player-only=Este comando solo está disponible dentro del juego. -whereami.header=Tu conexión actual en la red. -whereami.support= Estos datos ayudan al soporte a identificar problemas. -whereami.region= Región: -whereami.proxy= Proxy: -whereami.server= Servidor: +whereami.header=Tu ubicación en la red +whereami.support=\ Estos datos ayudan al soporte a identificar problemas. +whereami.region=\ Región: +whereami.proxy=\ Proxy: +whereami.server=\ Servidor: whereami.copy-hint=Haz clic para copiar region.player-only=Solo un jugador puede cambiar de región. region.none=No hay regiones configuradas en este proxy. diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 20361e2..569b582 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -10,11 +10,11 @@ online.region= — online.region.unknown=région inconnue — online.proxy=: whereami.player-only=Cette commande est disponible uniquement en jeu. -whereami.header=Votre connexion actuelle au réseau. -whereami.support= Ces informations aident le support à identifier les problèmes. -whereami.region= Région : -whereami.proxy= Proxy : -whereami.server= Serveur : +whereami.header=Votre position sur le réseau +whereami.support=\ Ces informations aident le support à identifier les problèmes. +whereami.region=\ Région : +whereami.proxy=\ Proxy : +whereami.server=\ Serveur : whereami.copy-hint=Cliquez pour copier region.player-only=Seul un joueur peut changer de région. region.none=Aucune région n'est configurée sur ce proxy. diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt index fb36b9a..46cbfdd 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt @@ -12,7 +12,10 @@ import java.net.InetSocketAddress import java.util.Locale import java.util.Optional import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.format.TextColor +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 @@ -51,18 +54,18 @@ class WhereAmICommandTest { fun `support block has one prefix and indented rows with copyable full IDs`() { val proxyId = "velocity-eu-5fbc57c96d-r8pf9" server = "minestom-lobby-7c77bc4b6f-9zpht" - WhereAmICommand({ "nl-ams1" }, { proxyId }, messages) + WhereAmICommand({ "nl-ams1" }, { proxyId }, messages, iconsReady = { true }) .execute(invocation(player(Locale.GERMAN))) val lines = plain().lines() assertEquals(6, lines.size) - assertTrue(lines[0].contains("Deine aktuelle Verbindung im Netzwerk."), plain()) + assertTrue(lines[0].contains("Dein Standort im Netzwerk"), plain()) assertTrue(lines[1].startsWith(" "), plain()) assertTrue(lines[1].contains("Support"), plain()) assertEquals("", lines[2]) - assertEquals(" Region: nl-ams1", lines[3]) - assertEquals(" Proxy: $proxyId", lines[4]) - assertEquals(" Server: $server", lines[5]) + assertEquals(" \uE410 Region: nl-ams1", lines[3]) + assertEquals(" \uE411 Proxy: $proxyId", lines[4]) + assertEquals(" \uE412 Server: $server", lines[5]) val copies = nodes(received.single()).filter { it.clickEvent() != null } assertEquals(2, copies.size) assertEquals(ClickEvent.copyToClipboard(proxyId), copies[0].clickEvent()) @@ -77,6 +80,29 @@ class WhereAmICommandTest { } } + @Test + fun `location block follows Minecraft Mini CD instead of web palette colors`() { + WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages, iconsReady = { true }) + .execute(invocation(player(Locale.GERMAN))) + + val content = nodes(received.single()).filterIsInstance() + val title = content.single { it.content() == "Dein Standort im Netzwerk" } + assertEquals(TextDecoration.State.TRUE, title.decoration(TextDecoration.BOLD)) + val region = content.single { it.content() == "nl-ams1" } + assertEquals(TextColor.color(0xF3F0EA), region.color()) + val copies = content.filter { it.clickEvent() != null } + assertEquals(2, copies.size) + copies.forEach { assertEquals(TextColor.color(0x67D9EC), it.color()) } + val icons = content.filter { it.content() in listOf("\uE410", "\uE411", "\uE412") } + assertEquals(3, icons.size) + icons.forEach { + assertEquals(TextColor.color(0xFFFFFF), it.color()) + assertEquals("grounds:connection", it.font()?.asString()) + } + val hint = content.single { it.content().contains("Support") } + assertEquals(TextColor.color(0xC1B9AA), hint.color()) + } + @Test fun `a backend switch is reflected on the next invocation`() { val command = WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages) @@ -128,6 +154,29 @@ class WhereAmICommandTest { } } + @Test + fun `unverified clients retain text labels without private glyphs`() { + WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages).execute(invocation(player())) + assertFalse(plain().any { it in '\uE000'..'\uF8FF' }) + assertTrue(plain().lines().contains(" Region: nl-ams1"), plain()) + } + + @Test + fun `connection glyphs require every allowed HUD pack to contain them`() { + assertFalse(WhereAmIGlyphs.enabled(emptyMap())) + assertFalse(WhereAmIGlyphs.enabled(mapOf("HUD_FONT_PACK_SHA1S" to "old"))) + assertFalse( + WhereAmIGlyphs.enabled( + mapOf("HUD_FONT_PACK_SHA1S" to "old,new", "WHEREAMI_FONT_PACK_SHA1S" to "new") + ) + ) + assertTrue( + WhereAmIGlyphs.enabled( + mapOf("HUD_FONT_PACK_SHA1S" to " new ", "WHEREAMI_FONT_PACK_SHA1S" to "new") + ) + ) + } + private fun nodes(component: Component): List = listOf(component) + component.children().flatMap(::nodes) From 686b917fe89fc1cecd38535c5807f14612fbc301 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 4 Nov 2026 22:53:16 +0100 Subject: [PATCH 10/13] fix: keep whereami help and hover within Mini-CD --- .../gg/grounds/proxy/velocity/command/WhereAmICommand.kt | 8 +++++++- .../main/resources/gg/grounds/proxy/messages.properties | 4 ++-- .../resources/gg/grounds/proxy/messages_de.properties | 4 ++-- .../resources/gg/grounds/proxy/messages_es.properties | 4 ++-- .../resources/gg/grounds/proxy/messages_fr.properties | 4 ++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt index 48cb93b..1491b5a 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt @@ -85,7 +85,13 @@ constructor( .clickEvent(ClickEvent.copyToClipboard(value)) .hoverEvent( HoverEvent.showText( - messages.render(ProxySystemMessage.WHEREAMI_COPY_HINT, player, "value" to value) + messages + .render( + ProxySystemMessage.WHEREAMI_COPY_HINT, + player, + "value" to Component.text(value, SECONDARY), + ) + .color(PRIMARY) ) ) } diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index 40d39af..5620ed9 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -13,11 +13,11 @@ online.region.unknown=unknown region — online.proxy=: whereami.player-only=This command is only available in-game. whereami.header=Your network location -whereami.support=\ These details help support investigate problems. +whereami.support=\ Share these details with support if you need help. whereami.region=\ Region: whereami.proxy=\ Proxy: whereami.server=\ Server: -whereami.copy-hint=Click to copy +whereami.copy-hint=Click to copy region.player-only=Only a player can change region. region.none=No regions are configured on this proxy. region.unknown=Unknown region ''. Available: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 0321397..d8ecd4a 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -11,11 +11,11 @@ online.region.unknown=unbekannte Region — online.proxy=: whereami.player-only=Diesen Befehl kannst du nur im Spiel nutzen. whereami.header=Dein Standort im Netzwerk -whereami.support=\ Diese Angaben helfen dem Support, Probleme einzugrenzen. +whereami.support=\ Bei Problemen helfen diese Angaben dem Support. whereami.region=\ Region: whereami.proxy=\ Proxy: whereami.server=\ Server: -whereami.copy-hint=Klicken zum Kopieren +whereami.copy-hint=Klicken zum Kopieren region.player-only=Nur Spieler können die Region wechseln. region.none=Auf diesem Proxy sind keine Regionen konfiguriert. region.unknown=Unbekannte Region ''. Verfügbar: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 0707680..0008f9a 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -11,11 +11,11 @@ online.region.unknown=región desconocida — online.proxy=: whereami.player-only=Este comando solo está disponible dentro del juego. whereami.header=Tu ubicación en la red -whereami.support=\ Estos datos ayudan al soporte a identificar problemas. +whereami.support=\ Estos datos ayudan al soporte si tienes problemas. whereami.region=\ Región: whereami.proxy=\ Proxy: whereami.server=\ Servidor: -whereami.copy-hint=Haz clic para copiar +whereami.copy-hint=Haz clic para copiar region.player-only=Solo un jugador puede cambiar de región. region.none=No hay regiones configuradas en este proxy. region.unknown=Región desconocida ''. Disponibles: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 569b582..2b5955f 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -11,11 +11,11 @@ online.region.unknown=région inconnue — online.proxy=: whereami.player-only=Cette commande est disponible uniquement en jeu. whereami.header=Votre position sur le réseau -whereami.support=\ Ces informations aident le support à identifier les problèmes. +whereami.support=\ Ces informations aident le support en cas de problème. whereami.region=\ Région : whereami.proxy=\ Proxy : whereami.server=\ Serveur : -whereami.copy-hint=Cliquez pour copier +whereami.copy-hint=Cliquez pour copier region.player-only=Seul un joueur peut changer de région. region.none=Aucune région n'est configurée sur ce proxy. region.unknown=Région inconnue ''. Disponibles : From 6a39dd2b30099005d74aade0dcb7961c3f4224ea Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 5 Nov 2026 08:53:18 +0100 Subject: [PATCH 11/13] feat: render larger welcome logo in a compact chat block --- .../plans/2026-11-05-welcome-mosaic.md | 35 ++++++++++++++++++ .../specs/2026-11-05-welcome-mosaic-design.md | 8 +++++ .../proxy/velocity/welcome/WelcomeGlyphs.kt | 8 ++++- .../proxy/velocity/welcome/WelcomeLayout.kt | 26 ++++++++------ .../proxy/velocity/welcome/WelcomeMessage.kt | 36 ++++++++++++------- .../gg/grounds/proxy/messages.properties | 2 ++ .../gg/grounds/proxy/messages_de.properties | 2 ++ .../gg/grounds/proxy/messages_es.properties | 2 ++ .../gg/grounds/proxy/messages_fr.properties | 2 ++ .../velocity/welcome/WelcomeMessageTest.kt | 17 ++++++++- 10 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-05-welcome-mosaic.md create mode 100644 docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md diff --git a/docs/superpowers/plans/2026-11-05-welcome-mosaic.md b/docs/superpowers/plans/2026-11-05-welcome-mosaic.md new file mode 100644 index 0000000..a0f6584 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-welcome-mosaic.md @@ -0,0 +1,35 @@ +# Welcome Mosaic Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan inline. + +**Goal:** A larger, sharper, seamless welcome logo that fits closed chat. +**Architecture:** Two native bitmap cells from existing branded artwork; compact decorated rendering; exact pack hash gates on Stage. +**Tech Stack:** Kotlin, Adventure, Java ImageIO, Minecraft bitmap fonts, Gradle, Argo. +**Spec:** ../specs/2026-11-05-welcome-mosaic-design.md + +## Global Constraints + +Ten rows, original colors, 14px social icons, separate click targets, conditional voice hint, fallback unchanged, preserve current Stage features. + +## Review Focus + +Native cell limits; seams from glyph advance; closed-chat cropping; four locales; old-pack fallback. + +## Task 1: Native mosaic + +- [ ] Add failing font tests for 512x241 source, two 256x241 cells, height56/ascent50 and fractional join spacing. +- [ ] Run focused test, then update WelcomeFont and pinned asset provenance. +- [ ] Verify each cell with native client atlas code and pixel reconstruction. + +## Task 2: Compact welcome + +- [ ] Add failing tests for both slices, ten rows, icon/logo bounds, all locales and unchanged fallback. +- [ ] Update WelcomeGlyphs, WelcomeLayout, WelcomeMessage and four compact greeting keys. +- [ ] Run focused tests then full Gradle test, spotlessApply and build for both projects. + +## Task 3: Stage + +- [ ] Review changes, sign isolated source commits and build immutable artifacts. +- [ ] Preserve active content ZIP and current Stage features; publish catalog, pack and proxy image. +- [ ] Update hash gates and pack selection with concurrency checks; activate Stage. +- [ ] Verify both Java proxies, Bedrock fallback and client atlas behavior. diff --git a/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md new file mode 100644 index 0000000..ebf8e17 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md @@ -0,0 +1,8 @@ +# Larger welcome logo + +The user chose a compact text area so everything remains visible in closed chat. +Use the original 512x241 artwork as two 256x241 bitmap glyphs. Render at height 56, ascent 50, with exact fractional spacing between slices. Each glyph must fit the native 256x256 atlas. + +The decorated welcome uses a short localized greeting and the existing three independently clickable social links on one centered line. Voice installation remains a separate full sentence and sand bullet, only for clients without the mod. Preserve fallback rendering, colors, links and multi-proxy behavior. + +Alternatives: a larger single glyph exceeds the native atlas; a taller message clips in closed chat. The user selected compact copy. Implementation choice: 56px height gives 40 percent more size and fits ten rows with the existing 14px icons. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt index 75745bc..9a4a740 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt @@ -8,7 +8,13 @@ internal object WelcomeGlyphs { const val TIKTOK = '\uE401' const val SHOP = '\uE402' const val LOGO = '\uE403' - const val LOGO_ADVANCE = 86 // 512x241 bitmap at height40, rounded advance plus1px glyph gap. + const val LOGO_RIGHT = '\uE404' + const val LOGO_JOIN = '\uE405' + const val LOGO_TEXT = "\uE403\uE405\uE404" + const val LOGO_SLICE_ADVANCE = 60.0 + // Each 256x241 cell renders 56*256/241 pixels wide; remove native rounded glyph spacing. + const val LOGO_JOIN_ADVANCE = -0.51452282157676 + const val LOGO_ADVANCE = 119.48547717842324 /** HUD readiness is sufficient only if every approved HUD pack contains these glyphs. */ fun supportedBy(hudPacks: Set, welcomePacks: Set): Boolean = diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt index 6850bbf..1a47180 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt @@ -14,10 +14,10 @@ internal object WelcomeLayout { private val defaultFont = Key.key("minecraft:default") fun center(line: Component, packReady: Boolean): Component { - val inset = ((320 - width(line)) / 2).coerceAtLeast(0) + val inset = ((320 - width(line)) / 2).coerceAtLeast(0.0) val padding = - if (packReady) Component.text(TabSpaces.of(inset)).font(TabGlyphs.FONT) - else Component.text(" ".repeat(inset / 4)).font(defaultFont) + if (packReady) Component.text(TabSpaces.of(inset.toInt())).font(TabGlyphs.FONT) + else Component.text(" ".repeat(inset.toInt() / 4)).font(defaultFont) // Padding is a sibling so it cannot inherit a link target or tint the bitmap. return Component.empty().append(padding).append(line) } @@ -26,7 +26,7 @@ internal object WelcomeLayout { component: Component, inheritedFont: Key = defaultFont, inheritedBold: Boolean = false, - ): Int { + ): Double { val font = component.font() ?: inheritedFont val bold = when (component.decoration(TextDecoration.BOLD)) { @@ -37,14 +37,18 @@ internal object WelcomeLayout { val own = (component as? TextComponent)?.content()?.sumOf { ch -> when { - font == TabGlyphs.FONT && ch == TabGlyphs.LOGO -> TabGlyphs.LOGO_ADVANCE - font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.LOGO -> - WelcomeGlyphs.LOGO_ADVANCE - font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.SHOP -> 13 - font == WelcomeGlyphs.FONT && ch in '\uE400'..'\uE401' -> 14 - else -> advance(ch) + if (bold && ch != ' ') 1 else 0 + font == TabGlyphs.FONT && ch == TabGlyphs.LOGO -> + TabGlyphs.LOGO_ADVANCE.toDouble() + font == WelcomeGlyphs.FONT && + (ch == WelcomeGlyphs.LOGO || ch == WelcomeGlyphs.LOGO_RIGHT) -> + WelcomeGlyphs.LOGO_SLICE_ADVANCE + font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.LOGO_JOIN -> + WelcomeGlyphs.LOGO_JOIN_ADVANCE + font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.SHOP -> 13.0 + font == WelcomeGlyphs.FONT && ch in '\uE400'..'\uE401' -> 14.0 + else -> (advance(ch) + if (bold && ch != ' ') 1 else 0).toDouble() } - } ?: 0 + } ?: 0.0 return own + component.children().sumOf { width(it, font, bold) } } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index c85c169..ed33994 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -37,6 +37,7 @@ constructor( ) { private enum class Copy(override val id: String) : MessageKey { GREETING("welcome.greeting"), + COMPACT_GREETING("welcome.greeting.compact"), VOICE("welcome.voice"), INSTALL("welcome.install"), } @@ -52,7 +53,7 @@ constructor( val decoratedLogo = decoratedLogoEnabled && logoReady && iconsReady val brand = if (decoratedLogo) - Component.text(WelcomeGlyphs.LOGO.toString()) + Component.text(WelcomeGlyphs.LOGO_TEXT) .font(WelcomeGlyphs.FONT) .color(TextColor.color(0xFFFFFF)) else if (logoReady) @@ -61,26 +62,28 @@ constructor( .color(TextColor.color(0xFFFFFF)) else Component.text("GROUNDS", GOLD, TextDecoration.BOLD) val result = Component.text().font(Key.key("minecraft:default")) - // The tall wordmark needs two reserved rows; use a third when voice help is absent. - // Together with the footer this keeps every bitmap inside the chat background. + // At height56/ascent50, row5 starts the mosaic at y2 and ends at y58. + // The compact social row7 starts its icons at y59, avoiding overlap. val leadingRows = when { - decoratedLogo -> if (voiceInstalled) 4 else 3 + decoratedLogo -> if (voiceInstalled) 6 else 5 logoReady -> if (voiceInstalled) 3 else 2 else -> 1 } repeat(leadingRows) { result.appendNewline() } result.append(WelcomeLayout.center(brand, logoReady)).appendNewline() if (logoReady) result.appendNewline() - result - .append( - WelcomeLayout.center( - messages.render(Copy.GREETING, locale, "player" to playerName), - logoReady, + if (!decoratedLogo) { + result + .append( + WelcomeLayout.center( + messages.render(Copy.GREETING, locale, "player" to playerName), + logoReady, + ) ) - ) - .appendNewline() - .appendNewline() + .appendNewline() + .appendNewline() + } val community = Component.empty() .append( @@ -104,7 +107,14 @@ constructor( .append( communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady) ) - result.append(WelcomeLayout.center(community, logoReady)) + val communityRow = + if (decoratedLogo) + Component.empty() + .append(messages.render(Copy.COMPACT_GREETING, locale)) + .append(Component.text(" ")) + .append(community) + else community + result.append(WelcomeLayout.center(communityRow, logoReady)) if (!voiceInstalled) { result .appendNewline() diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index dc9119c..43e0854 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -88,3 +88,5 @@ proxy.usage=Click a proxy to switch. welcome.greeting=Welcome, <#E7B84B>! welcome.voice=<#C1B9AA>We support party, game and proximity voice chat welcome.install=<#67D9EC>Install + +welcome.greeting.compact=Welcome! diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 52fac82..039b595 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -87,3 +87,5 @@ proxy.usage=Klicke auf einen Proxy, um zu wechseln. welcome.greeting=Willkommen, <#E7B84B>! welcome.voice=<#C1B9AA>Wir bieten Voice Chat in Partys, Spielen und lokal welcome.install=<#67D9EC>Installieren + +welcome.greeting.compact=Willkommen! diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 2faf7a3..ee06275 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -87,3 +87,5 @@ proxy.usage=Haz clic en un proxy para cambiar. welcome.greeting=¡Bienvenido, <#E7B84B>! welcome.voice=<#C1B9AA>Hay chat de voz grupal, en partida y por cercanía welcome.install=<#67D9EC>Instalar + +welcome.greeting.compact=¡Bienvenido! diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 7c3d312..f24f8f1 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -87,3 +87,5 @@ proxy.usage=Cliquez sur un proxy pour changer. welcome.greeting=Bienvenue, <#E7B84B> ! welcome.voice=<#C1B9AA>Le vocal marche en groupe, en jeu et à proximité welcome.install=<#67D9EC>Installer + +welcome.greeting.compact=Bienvenue ! diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index 2f2330c..31ebd9a 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -76,7 +76,22 @@ class WelcomeMessageTest { val message = decorated.render("Alex", locale, true, installed, true) val lines = plain(message).lines() assertEquals(10, lines.size) - assertTrue(lines[if (installed) 4 else 3].contains('\uE403')) + val brandRow = if (installed) 6 else 5 + val linksRow = if (installed) 8 else 7 + assertTrue(lines[brandRow].contains("\uE403\uE405\uE404")) + assertTrue(lines[linksRow].contains("Discord")) + assertFalse( + plain(message).contains("Alex"), + "Compact greeting leaves room for links", + ) + val logoTop = brandRow * 9 + 7 - 50 + val logoBottom = logoTop + 56 + val iconsTop = linksRow * 9 + 7 - 11 + assertTrue(logoTop >= 0) + assertTrue(logoBottom <= iconsTop, "Logo must not overlap social icons") + assertTrue(iconsTop + 14 <= 90, "Icons must remain inside chat background") + + assertTrue(lines[if (installed) 6 else 5].contains('\uE403')) assertTrue( nodes(message).any { it.font() == Key.key("grounds:welcome") && plain(it).contains('\uE403') From 79ebaac5449d867aa3c65d5a80a3441430d8d841 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 5 Nov 2026 18:05:02 +0100 Subject: [PATCH 12/13] fix: restore personalized welcome and pad the voice footer --- .../specs/2026-11-05-welcome-mosaic-design.md | 2 +- .../proxy/velocity/welcome/WelcomeMessage.kt | 43 +++++++++---------- .../gg/grounds/proxy/messages.properties | 2 - .../gg/grounds/proxy/messages_de.properties | 2 - .../gg/grounds/proxy/messages_es.properties | 2 - .../gg/grounds/proxy/messages_fr.properties | 2 - .../velocity/welcome/WelcomeMessageTest.kt | 42 +++++++++++++++--- 7 files changed, 58 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md index ebf8e17..3c34aa3 100644 --- a/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md +++ b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md @@ -3,6 +3,6 @@ The user chose a compact text area so everything remains visible in closed chat. Use the original 512x241 artwork as two 256x241 bitmap glyphs. Render at height 56, ascent 50, with exact fractional spacing between slices. Each glyph must fit the native 256x256 atlas. -The decorated welcome uses a short localized greeting and the existing three independently clickable social links on one centered line. Voice installation remains a separate full sentence and sand bullet, only for clients without the mod. Preserve fallback rendering, colors, links and multi-proxy behavior. +The decorated welcome uses a personalized localized greeting and the existing three independently clickable social links on one centered line. The player name remains present, with reduced spaces between social links. The full icon bitmap uses ascent 12 so voice copy can follow on the adjacent row; the blank footer stays below that copy. Voice installation remains a separate full sentence and sand bullet, only for clients without the mod. Preserve fallback rendering, colors, links and multi-proxy behavior. Alternatives: a larger single glyph exceeds the native atlas; a taller message clips in closed chat. The user selected compact copy. Implementation choice: 56px height gives 40 percent more size and fits ten rows with the existing 14px icons. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index ed33994..54f7fcf 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -37,7 +37,6 @@ constructor( ) { private enum class Copy(override val id: String) : MessageKey { GREETING("welcome.greeting"), - COMPACT_GREETING("welcome.greeting.compact"), VOICE("welcome.voice"), INSTALL("welcome.install"), } @@ -63,7 +62,7 @@ constructor( else Component.text("GROUNDS", GOLD, TextDecoration.BOLD) val result = Component.text().font(Key.key("minecraft:default")) // At height56/ascent50, row5 starts the mosaic at y2 and ends at y58. - // The compact social row7 starts its icons at y59, avoiding overlap. + // The compact social row7 starts its icons at y58, avoiding overlap. val leadingRows = when { decoratedLogo -> if (voiceInstalled) 6 else 5 @@ -84,6 +83,7 @@ constructor( .appendNewline() .appendNewline() } + val communityGap = Component.text(if (decoratedLogo) " " else " ") val community = Component.empty() .append( @@ -94,7 +94,7 @@ constructor( iconsReady && logoReady, ) ) - .append(Component.text(" ")) + .append(communityGap) .append( communityLink( WelcomeGlyphs.TIKTOK, @@ -103,38 +103,35 @@ constructor( iconsReady && logoReady, ) ) - .append(Component.text(" ")) + .append(communityGap) .append( communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady) ) val communityRow = if (decoratedLogo) Component.empty() - .append(messages.render(Copy.COMPACT_GREETING, locale)) - .append(Component.text(" ")) + .append(messages.render(Copy.GREETING, locale, "player" to playerName)) + .append(Component.space()) .append(community) else community result.append(WelcomeLayout.center(communityRow, logoReady)) if (!voiceInstalled) { - result - .appendNewline() - .appendNewline() - .append( - WelcomeLayout.center( - Component.empty() - .append(messages.render(Copy.VOICE, locale)) - .append(Component.text(" • ", SAND)) - .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)), - logoReady, - ) + result.appendNewline() + // The compact pack aligns the full icon bitmap above the following normal text row; + // reserve the spare row below the copy. + if (!decoratedLogo) result.appendNewline() + result.append( + WelcomeLayout.center( + Component.empty() + .append(messages.render(Copy.VOICE, locale)) + .append(Component.text(" • ", SAND)) + .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)), + logoReady, ) + ) } - // Bitmap icons extend below a normal text row, so never end the background at their - // baseline. - // The taller decorated logo uses the full ten rows with voice help. That variant ends in - // normal-height text; the icon row already has a blank row below it. - return if (decoratedLogo && !voiceInstalled) result.build() - else result.appendNewline().build() + // Always retain background below the last sentence or bitmap icon row. + return result.appendNewline().build() } private fun communityLink( diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index 43e0854..dc9119c 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -88,5 +88,3 @@ proxy.usage=Click a proxy to switch. welcome.greeting=Welcome, <#E7B84B>! welcome.voice=<#C1B9AA>We support party, game and proximity voice chat welcome.install=<#67D9EC>Install - -welcome.greeting.compact=Welcome! diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 039b595..52fac82 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -87,5 +87,3 @@ proxy.usage=Klicke auf einen Proxy, um zu wechseln. welcome.greeting=Willkommen, <#E7B84B>! welcome.voice=<#C1B9AA>Wir bieten Voice Chat in Partys, Spielen und lokal welcome.install=<#67D9EC>Installieren - -welcome.greeting.compact=Willkommen! diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index ee06275..2faf7a3 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -87,5 +87,3 @@ proxy.usage=Haz clic en un proxy para cambiar. welcome.greeting=¡Bienvenido, <#E7B84B>! welcome.voice=<#C1B9AA>Hay chat de voz grupal, en partida y por cercanía welcome.install=<#67D9EC>Instalar - -welcome.greeting.compact=¡Bienvenido! diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index f24f8f1..7c3d312 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -87,5 +87,3 @@ proxy.usage=Cliquez sur un proxy pour changer. welcome.greeting=Bienvenue, <#E7B84B> ! welcome.voice=<#C1B9AA>Le vocal marche en groupe, en jeu et à proximité welcome.install=<#67D9EC>Installer - -welcome.greeting.compact=Bienvenue ! diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index 31ebd9a..aa0d9c6 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -80,13 +80,18 @@ class WelcomeMessageTest { val linksRow = if (installed) 8 else 7 assertTrue(lines[brandRow].contains("\uE403\uE405\uE404")) assertTrue(lines[linksRow].contains("Discord")) - assertFalse( - plain(message).contains("Alex"), - "Compact greeting leaves room for links", - ) + assertTrue(lines.last().isEmpty(), "Reserve a blank row below the final sentence") + assertTrue(lines[linksRow].contains("Alex"), "Keep the personalized greeting") + if (!installed) { + assertTrue(lines[8].contains(" • "), "Voice copy follows the social row") + assertTrue( + iconsTopForRow(linksRow) + 14 <= 8 * 9, + "The full icon bitmap ends before adjacent voice text", + ) + } val logoTop = brandRow * 9 + 7 - 50 val logoBottom = logoTop + 56 - val iconsTop = linksRow * 9 + 7 - 11 + val iconsTop = linksRow * 9 + 7 - 12 assertTrue(logoTop >= 0) assertTrue(logoBottom <= iconsTop, "Logo must not overlap social icons") assertTrue(iconsTop + 14 <= 90, "Icons must remain inside chat background") @@ -112,6 +117,33 @@ class WelcomeMessageTest { ) } + private fun iconsTopForRow(row: Int) = row * 9 + 7 - 12 + + @Test + fun `personalized compact greeting fits maximum width names in every locale`() { + val decorated = + WelcomeMessage( + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), + decoratedLogoEnabled = true, + ) + val name = "W".repeat(16) + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + for (installed in listOf(false, true)) { + val message = decorated.render(name, locale, true, installed, true) + val lines = plain(message).lines() + val row = lines[if (installed) 8 else 7] + assertEquals(10, lines.size) + assertTrue(row.contains(name), "Do not remove or truncate long player names") + assertTrue( + row.first() in '\uE010'..'\uE025', + "Maximum-width name must leave positive centering space: $locale", + ) + assertEquals(if (installed) 3 else 4, nodes(message).mapNotNull { url(it) }.size) + } + } + } + @Test fun `links below the brand have independent click targets`() { val message = renderer.render("Alex", Locale.ENGLISH, true, true, true) From 63d95e36d118f32ff07de00eeb1431c3c3565b66 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 5 Nov 2026 18:29:02 +0100 Subject: [PATCH 13/13] fix: separate welcome greeting and community links --- .../2026-11-05-welcome-separated-lines.md | 9 +++++ .../specs/2026-11-05-welcome-mosaic-design.md | 11 +++--- .../proxy/velocity/welcome/WelcomeGlyphs.kt | 8 ++-- .../proxy/velocity/welcome/WelcomeMessage.kt | 37 +++++++------------ .../velocity/welcome/WelcomeMessageTest.kt | 22 +++++++---- 5 files changed, 48 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-05-welcome-separated-lines.md diff --git a/docs/superpowers/plans/2026-11-05-welcome-separated-lines.md b/docs/superpowers/plans/2026-11-05-welcome-separated-lines.md new file mode 100644 index 0000000..811318c --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-welcome-separated-lines.md @@ -0,0 +1,9 @@ +# Separate welcome rows + +The user approved a slightly smaller logo to keep everything visible in the default ten-row chat. + +- Test the 42px logo at ascent41, preserving two 256x241 source cells and exact fractional join spacing. +- Test separate centered greeting/name and social rows, full icon bounds, voice copy and blank footer in ten rows for all four locales. +- Implement using existing welcome fonts and copy; preserve link targets, conditional voice hint and fallback. +- Run test, spotlessApply and build; sign source commits and publish immutable PackSet v1.2.2-stage-welcome.6 plus matching proxy image. +- Preserve current Stage features and content; verify all three proxies after staged rollout. diff --git a/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md index 3c34aa3..ade2dae 100644 --- a/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md +++ b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md @@ -1,8 +1,9 @@ -# Larger welcome logo +# Welcome layout with separate community links -The user chose a compact text area so everything remains visible in closed chat. -Use the original 512x241 artwork as two 256x241 bitmap glyphs. Render at height 56, ascent 50, with exact fractional spacing between slices. Each glyph must fit the native 256x256 atlas. +The user approved a smaller logo to keep the greeting, community links, voice hint and blank footer fully visible in the default ten-row chat. Keep the original 512x241 artwork and two 256x241 native cells; render the logo at height42/ascent41 with exact fractional join spacing. -The decorated welcome uses a personalized localized greeting and the existing three independently clickable social links on one centered line. The player name remains present, with reduced spaces between social links. The full icon bitmap uses ascent 12 so voice copy can follow on the adjacent row; the blank footer stays below that copy. Voice installation remains a separate full sentence and sand bullet, only for clients without the mod. Preserve fallback rendering, colors, links and multi-proxy behavior. +Center each row separately. The greeting retains the player name, and Discord/TikTok/Shop follow on their own row with three-space gaps. Preserve Mini-CD colors, independent click targets, conditional voice installation copy, the sand bullet and existing fallback. -Alternatives: a larger single glyph exceeds the native atlas; a taller message clips in closed chat. The user selected compact copy. Implementation choice: 56px height gives 40 percent more size and fits ten rows with the existing 14px icons. +Without the voice mod, the logo is on row4 (ink bounds y2–44), greeting row5 (text y45), social icons row7 (bitmap y58–72), voice row8 (text y72), and blank footer row9. With the mod installed, shift the logo, greeting and socials down one row and omit voice copy, retaining footer row9. Social icon height14/ascent12 remains unchanged. + +A taller twelve-row block with the large56px logo was considered and rejected because it clips in closed chat. The chosen42px logo retains higher-resolution source artwork. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt index 9a4a740..97c0bcd 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt @@ -11,10 +11,10 @@ internal object WelcomeGlyphs { const val LOGO_RIGHT = '\uE404' const val LOGO_JOIN = '\uE405' const val LOGO_TEXT = "\uE403\uE405\uE404" - const val LOGO_SLICE_ADVANCE = 60.0 - // Each 256x241 cell renders 56*256/241 pixels wide; remove native rounded glyph spacing. - const val LOGO_JOIN_ADVANCE = -0.51452282157676 - const val LOGO_ADVANCE = 119.48547717842324 + const val LOGO_SLICE_ADVANCE = 46.0 + // Each 256x241 cell renders 42*256/241 pixels wide; remove native rounded glyph spacing. + const val LOGO_JOIN_ADVANCE = -1.38589211618257 + const val LOGO_ADVANCE = 90.61410788381743 /** HUD readiness is sufficient only if every approved HUD pack contains these glyphs. */ fun supportedBy(hudPacks: Set, welcomePacks: Set): Boolean = diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt index 54f7fcf..f46ab51 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -61,29 +61,27 @@ constructor( .color(TextColor.color(0xFFFFFF)) else Component.text("GROUNDS", GOLD, TextDecoration.BOLD) val result = Component.text().font(Key.key("minecraft:default")) - // At height56/ascent50, row5 starts the mosaic at y2 and ends at y58. - // The compact social row7 starts its icons at y58, avoiding overlap. + // At height42/ascent41, row4 starts the mosaic at y2 and ends at y44. + // The greeting starts at y45; the separate social row7 starts its icons at y58. val leadingRows = when { - decoratedLogo -> if (voiceInstalled) 6 else 5 + decoratedLogo -> if (voiceInstalled) 5 else 4 logoReady -> if (voiceInstalled) 3 else 2 else -> 1 } repeat(leadingRows) { result.appendNewline() } result.append(WelcomeLayout.center(brand, logoReady)).appendNewline() - if (logoReady) result.appendNewline() - if (!decoratedLogo) { - result - .append( - WelcomeLayout.center( - messages.render(Copy.GREETING, locale, "player" to playerName), - logoReady, - ) + if (logoReady && !decoratedLogo) result.appendNewline() + result + .append( + WelcomeLayout.center( + messages.render(Copy.GREETING, locale, "player" to playerName), + logoReady, ) - .appendNewline() - .appendNewline() - } - val communityGap = Component.text(if (decoratedLogo) " " else " ") + ) + .appendNewline() + .appendNewline() + val communityGap = Component.text(" ") val community = Component.empty() .append( @@ -107,14 +105,7 @@ constructor( .append( communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady) ) - val communityRow = - if (decoratedLogo) - Component.empty() - .append(messages.render(Copy.GREETING, locale, "player" to playerName)) - .append(Component.space()) - .append(community) - else community - result.append(WelcomeLayout.center(communityRow, logoReady)) + result.append(WelcomeLayout.center(community, logoReady)) if (!voiceInstalled) { result.appendNewline() // The compact pack aligns the full icon bitmap above the following normal text row; diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt index aa0d9c6..588d67b 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -76,12 +76,19 @@ class WelcomeMessageTest { val message = decorated.render("Alex", locale, true, installed, true) val lines = plain(message).lines() assertEquals(10, lines.size) - val brandRow = if (installed) 6 else 5 + val brandRow = if (installed) 5 else 4 val linksRow = if (installed) 8 else 7 assertTrue(lines[brandRow].contains("\uE403\uE405\uE404")) assertTrue(lines[linksRow].contains("Discord")) assertTrue(lines.last().isEmpty(), "Reserve a blank row below the final sentence") - assertTrue(lines[linksRow].contains("Alex"), "Keep the personalized greeting") + val greetingRow = if (installed) 6 else 5 + assertTrue(lines[greetingRow].contains("Alex"), "Keep the personalized greeting") + assertFalse( + lines[greetingRow].contains("Discord"), + "Social links have their own row", + ) + assertFalse(lines[linksRow].contains("Alex"), "Keep the social row independent") + assertTrue(lines[greetingRow + 1].isEmpty(), "Leave room above tall icons") if (!installed) { assertTrue(lines[8].contains(" • "), "Voice copy follows the social row") assertTrue( @@ -89,14 +96,15 @@ class WelcomeMessageTest { "The full icon bitmap ends before adjacent voice text", ) } - val logoTop = brandRow * 9 + 7 - 50 - val logoBottom = logoTop + 56 + val logoTop = brandRow * 9 + 7 - 41 + val logoBottom = logoTop + 42 val iconsTop = linksRow * 9 + 7 - 12 assertTrue(logoTop >= 0) + assertTrue(logoBottom < greetingRow * 9, "Logo must end before greeting ink") assertTrue(logoBottom <= iconsTop, "Logo must not overlap social icons") assertTrue(iconsTop + 14 <= 90, "Icons must remain inside chat background") - assertTrue(lines[if (installed) 6 else 5].contains('\uE403')) + assertTrue(lines[if (installed) 5 else 4].contains('\uE403')) assertTrue( nodes(message).any { it.font() == Key.key("grounds:welcome") && plain(it).contains('\uE403') @@ -120,7 +128,7 @@ class WelcomeMessageTest { private fun iconsTopForRow(row: Int) = row * 9 + 7 - 12 @Test - fun `personalized compact greeting fits maximum width names in every locale`() { + fun `personalized separate greeting fits maximum width names in every locale`() { val decorated = WelcomeMessage( Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), @@ -132,7 +140,7 @@ class WelcomeMessageTest { for (installed in listOf(false, true)) { val message = decorated.render(name, locale, true, installed, true) val lines = plain(message).lines() - val row = lines[if (installed) 8 else 7] + val row = lines[if (installed) 6 else 5] assertEquals(10, lines.size) assertTrue(row.contains(name), "Do not remove or truncate long player names") assertTrue(