From d8e25ed68d14bc976e8b30c867670e5a267d373b Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 14 Oct 2026 23:29:09 +0200 Subject: [PATCH 1/4] feat(chat): unify localized player messages --- velocity/build.gradle.kts | 1 + .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 33 ++--- .../main/kotlin/gg/grounds/PlayerMessage.kt | 22 +++ .../gg/grounds/config/MessagesConfig.kt | 17 +-- .../gg/grounds/edition/FloodgateLookup.kt | 86 ++++++++---- .../edition/listener/EditionStampListener.kt | 48 ++++++- .../kotlin/gg/grounds/link/LinkCommand.kt | 55 +++----- .../grounds/listener/LoginMessageRenderer.kt | 15 ++ .../listener/PlayerConnectionListener.kt | 37 ++++- .../gg/grounds/locale/commands/LangCommand.kt | 31 ++--- .../presentation/VelocitySystemMessages.kt | 21 +++ .../gg/grounds/player/messages.properties | 15 ++ .../gg/grounds/player/messages_de.properties | 15 ++ .../gg/grounds/player/messages_es.properties | 15 ++ .../gg/grounds/player/messages_fr.properties | 15 ++ velocity/src/main/resources/messages.yml | 9 +- .../kotlin/gg/grounds/PlayerMessagesTest.kt | 45 ++++++ .../edition/EditionStampListenerTest.kt | 130 +++++++++++++++++- .../listener/LoginMessageRendererTest.kt | 36 +++++ 19 files changed, 511 insertions(+), 135 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/listener/LoginMessageRenderer.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/presentation/VelocitySystemMessages.kt create mode 100644 velocity/src/main/resources/gg/grounds/player/messages.properties create mode 100644 velocity/src/main/resources/gg/grounds/player/messages_de.properties create mode 100644 velocity/src/main/resources/gg/grounds/player/messages_es.properties create mode 100644 velocity/src/main/resources/gg/grounds/player/messages_fr.properties create mode 100644 velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/listener/LoginMessageRendererTest.kt diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index ecdfe7b..97709f6 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -12,6 +12,7 @@ repositories { dependencies { implementation(project(":common")) + implementation("gg.grounds:library-i18n:0.3.0") // plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the // registry this plugin writes into would be a different class from the one chat/social read. compileOnly("gg.grounds:plugin-proxy-api:0.5.0") diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index 8e8548f..7a5dd36 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -8,10 +8,10 @@ import com.velocitypowered.api.plugin.Dependency import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer -import gg.grounds.config.MessagesConfig import gg.grounds.config.MessagesConfigLoader import gg.grounds.edition.FloodgateLookup import gg.grounds.edition.listener.EditionStampListener +import gg.grounds.i18n.Translations import gg.grounds.link.ForgeLinkClient import gg.grounds.link.LinkCommand import gg.grounds.listener.PlayerConnectionListener @@ -22,6 +22,7 @@ import gg.grounds.locale.listener.LocaleConnectionListener import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService import gg.grounds.presence.PlayerSessionQueryImpl +import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.PlayerLocaleQuery import gg.grounds.proxy.api.PlayerSessionQuery import gg.grounds.proxy.api.ProxyServiceRegistry @@ -36,7 +37,7 @@ import org.slf4j.Logger authors = ["Grounds Development Team and contributors"], url = "https://github.com/groundsgg/plugin-player", // plugin-proxy owns the ProxyServiceRegistry this plugin publishes its session lookup into. - dependencies = [Dependency(id = "plugin-proxy")], + dependencies = [Dependency(id = "plugin-proxy"), Dependency(id = "floodgate", optional = true)], ) class GroundsPluginPlayer @Inject @@ -57,6 +58,12 @@ constructor( @Subscribe fun onInitialize(event: ProxyInitializeEvent) { val messages = MessagesConfigLoader(logger, dataDirectory).loadOrCreate() + val translations = + Translations.forBundleWithSystemRenderer( + "gg.grounds.player.messages", + javaClass.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ) val serviceUrl = resolveServiceUrl() playerPresenceService.configure(serviceUrl) @@ -91,35 +98,23 @@ constructor( PlayerLocaleQueryImpl(playerLocaleCache), ) proxy.commandManager.register( - LangCommand.create(playerLocaleCache, playerPresenceService, logger) + LangCommand.create(playerLocaleCache, playerPresenceService, logger, translations) ) - registerLinkCommands(messages) + registerLinkCommands(translations) registerEditionStamp() heartbeatScheduler.start() logger.info("Configured player presence client (serviceUrl={})", serviceUrl) } - /** - * Tells backends which of their players came from Bedrock, by stamping a property onto the - * signed part of the forwarding payload. - * - * Only the Bedrock proxy carries Floodgate, so on the Java proxies there is nothing to ask and - * the listener is not registered at all — rather than registered and answering "Java" for - * everyone, which is the same outcome for more moving parts. - */ + /** Stamps an authenticated Java or Bedrock edition into the signed forwarding profile. */ private fun registerEditionStamp() { val floodgate = FloodgateLookup.create(logger) - if (floodgate == null) { - logger.debug("Floodgate not installed; backends see no edition marker from this proxy") - return - } proxy.eventManager.register(this, EditionStampListener(floodgate, logger)) logger.info( - "Marking Bedrock players for backends ({}={})", + "Marking authenticated player edition for backends ({})", EditionStampListener.PROPERTY, - EditionStampListener.BEDROCK, ) } @@ -128,7 +123,7 @@ constructor( * pushed workloads. Outside that context (a bare local proxy, say) the env vars are absent — * skip the commands rather than register ones that can only ever fail. */ - private fun registerLinkCommands(messages: MessagesConfig) { + private fun registerLinkCommands(messages: Translations) { val forgeUrl = System.getenv("GROUNDS_FORGE_URL")?.trim()?.trimEnd('/') if (forgeUrl.isNullOrEmpty()) { logger.info("GROUNDS_FORGE_URL is not set; /link is unavailable") diff --git a/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt b/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt new file mode 100644 index 0000000..b4e8a5a --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt @@ -0,0 +1,22 @@ +package gg.grounds + +import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemMessageKey + +enum class PlayerMessage(override val id: String, override val category: SystemCategory) : + SystemMessageKey { + LOGIN_SERVICE_UNAVAILABLE("login.service-unavailable", SystemCategory.ERROR), + LOGIN_ALREADY_ONLINE("login.already-online", SystemCategory.ERROR), + LOGIN_INVALID_REQUEST("login.invalid-request", SystemCategory.ERROR), + LOGIN_ERROR("login.error", SystemCategory.ERROR), + LANG_USAGE("lang.usage", SystemCategory.WARNING), + LANG_PLAYERS_ONLY("lang.players-only", SystemCategory.ERROR), + LANG_UNKNOWN("lang.unknown", SystemCategory.ERROR), + LANG_SET("lang.set", SystemCategory.SUCCESS), + LINK_PLAYERS_ONLY("link.players-only", SystemCategory.ERROR), + LINK_WORKING("link.working", SystemCategory.INFO), + LINK_PROMPT("link.prompt", SystemCategory.INFO), + LINK_CLICK("link.click", SystemCategory.INFO), + LINK_FAILED("link.failed", SystemCategory.ERROR), + UNLINK_DONE("unlink.done", SystemCategory.SUCCESS), +} diff --git a/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt index d6b3b29..8b900f1 100644 --- a/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt +++ b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt @@ -1,16 +1,9 @@ package gg.grounds.config +/** Optional literal operator overrides; null uses the shared locale-aware source bundle. */ data class MessagesConfig( - val serviceUnavailable: String = "Login service unavailable", - val alreadyOnline: String = "You are already online.", - val invalidRequest: String = "Invalid login request.", - val genericError: String = "Unable to create player session.", - // /link and /unlink. The consent itself happens in a browser, so the - // prompt's job is to explain why a player is being sent out of the game. - val linkPlayersOnly: String = "Only players can link a Microsoft account.", - val linkWorking: String = "Preparing your Microsoft sign-in...", - val linkPrompt: String = "Sign in with Microsoft to find friends who play here:", - val linkClickHere: String = "[Click to connect]", - val linkFailed: String = "Could not start the link right now. Please try again.", - val unlinkDone: String = "Your Microsoft account has been disconnected.", + val serviceUnavailable: String? = null, + val alreadyOnline: String? = null, + val invalidRequest: String? = null, + val genericError: String? = null, ) diff --git a/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt b/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt index 13a88f4..42c5366 100644 --- a/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt +++ b/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt @@ -13,8 +13,8 @@ import org.slf4j.Logger * one method costs less than either, and `library-gui` already avoids the same dependency for the * same reason. * - * [create] returns null when Floodgate is not installed, which is the ordinary state of the Java - * proxies rather than an error worth logging loudly. + * [create] treats a missing Floodgate class as positive evidence that this is a Java-only proxy. A + * present but incompatible or failing API remains unknown instead. * * **Why not just look at the UUID.** Floodgate builds an unlinked Bedrock player's UUID as `new * UUID(0, xuid)`, so the shape alone answers for them — which is what backends do today. It does @@ -29,55 +29,95 @@ import org.slf4j.Logger class FloodgateLookup private constructor( private val logger: Logger, - private val getInstance: Method, - private val isFloodgatePlayer: Method, + private val availability: Availability, + private val getInstance: Method? = null, + private val isFloodgatePlayer: Method? = null, ) { @Volatile private var failed = false - /** - * Whether Floodgate knows this player as a Bedrock player. - * - * Answers false on any failure, and the direction is deliberate: false means "treat as Java", - * which downstream means "keep checking them". The opposite default would let a Floodgate - * hiccup quietly exempt players from anti-cheat. - */ - fun isBedrock(playerId: UUID): Boolean { - if (failed) return false + /** True for Bedrock, false for known Java, and null when Floodgate cannot classify safely. */ + fun classify(playerId: UUID): Boolean? { + if (availability == Availability.JAVA_ONLY) return false + if (availability == Availability.UNKNOWN || failed) return null return try { // Resolved per call rather than cached: Floodgate's singleton is not guaranteed to // exist when this proxy wires its listeners, only by the time a player logs in. - val api = getInstance.invoke(null) ?: return false - isFloodgatePlayer.invoke(api, playerId) as Boolean + val api = getInstance!!.invoke(null) ?: return lookupFailed(playerId, null) + isFloodgatePlayer!!.invoke(api, playerId) as? Boolean + ?: return lookupFailed(playerId, null) } catch (e: ReflectiveOperationException) { // Once, not per login: a broken API would otherwise write a line for every player who // ever joins. - failed = true - logger.warn("Floodgate lookup failed; treating every player as Java from here on", e) - false + lookupFailed(playerId, e) + } catch (e: RuntimeException) { + lookupFailed(playerId, e) } } + /** + * Whether Floodgate knows this player as a Bedrock player. + * + * Answers false on any failure, and the direction is deliberate: false means "treat as Java" + * for anti-cheat, which keeps checking the player. Edition stamping uses [classify] so the same + * failure cannot become an affirmative Java marker. + */ + fun isBedrock(playerId: UUID): Boolean = classify(playerId) == true + + private fun lookupFailed(playerId: UUID, cause: Throwable?): Boolean? { + failed = true + if (cause == null) { + logger.warn( + "Floodgate lookup returned no classification for {}; edition stamping is disabled", + playerId, + ) + } else { + logger.warn( + "Floodgate lookup failed; edition stamping is disabled and anti-cheat will treat players as Java", + cause, + ) + } + return null + } + companion object { private const val API = "org.geysermc.floodgate.api.FloodgateApi" - /** Null when Floodgate is not installed on this proxy. */ - fun create(logger: Logger): FloodgateLookup? = + /** + * Creates a classifier on every proxy. Missing Floodgate means this is a Java-only proxy; a + * present but incompatible API is unknown and must not produce a Java marker. + */ + fun create(logger: Logger): FloodgateLookup = create(logger) { Class.forName(API) } + + internal fun create(logger: Logger, loadApi: () -> Class<*>): FloodgateLookup = try { - val api = Class.forName(API) + val api = loadApi() FloodgateLookup( logger, + Availability.FLOODGATE, api.getMethod("getInstance"), api.getMethod("isFloodgatePlayer", UUID::class.java), ) } catch (e: ClassNotFoundException) { - null + FloodgateLookup(logger, Availability.JAVA_ONLY) } catch (e: NoSuchMethodException) { // Floodgate is here but is not the API this expects. Say so: a silent null would // hide a version skew until someone wondered why Bedrock players were being // flagged by anti-cheat. logger.warn("Floodgate is installed but {} is not the expected shape", API, e) - null + FloodgateLookup(logger, Availability.UNKNOWN) + } catch (e: LinkageError) { + logger.warn("Floodgate is installed but {} could not be loaded", API, e) + FloodgateLookup(logger, Availability.UNKNOWN) + } catch (e: SecurityException) { + logger.warn("Floodgate is installed but {} cannot be inspected", API, e) + FloodgateLookup(logger, Availability.UNKNOWN) } + + private enum class Availability { + JAVA_ONLY, + FLOODGATE, + UNKNOWN, + } } } diff --git a/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt b/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt index 9ba2390..82d3799 100644 --- a/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt @@ -30,7 +30,8 @@ import org.slf4j.Logger * **Ordering.** Floodgate's own skin applier also rewrites the property list, but it copies the * list and removes only `textures`, so it cannot drop this one whichever way round the two run. * - * Registered only when Floodgate is installed, which in practice means only on the Bedrock proxy. + * Registered on every proxy. Missing Floodgate identifies a Java-only proxy; a broken Floodgate API + * remains unknown and cannot create an affirmative Java marker. */ class EditionStampListener(private val floodgate: FloodgateLookup, private val logger: Logger) { @@ -40,24 +41,41 @@ class EditionStampListener(private val floodgate: FloodgateLookup, private val l } private fun stamp(player: Player) { - if (!floodgate.isBedrock(player.uniqueId)) return - - val updated = withEditionProperty(player.gameProfileProperties) ?: return + val edition = + when (floodgate.classify(player.uniqueId)) { + true -> BEDROCK + false -> JAVA + null -> null + } + val updated = withEditionProperty(player.gameProfileProperties, edition) + if (updated == player.gameProfileProperties) return player.gameProfileProperties = updated - logger.debug("Marked {} as a Bedrock player ({}={})", player.username, PROPERTY, BEDROCK) + if (edition == null) { + logger.debug("Removed unverified edition marker from {}", player.username) + } else { + logger.debug( + "Marked {} with authenticated edition ({}={})", + player.username, + PROPERTY, + edition, + ) + } } companion object { /** * Namespaced because it rides in a list Mojang also writes to — `textures` is theirs. * - * Backends match on the name and treat any other value, or its absence, as Java. + * Backends require one of the authenticated values written below. */ const val PROPERTY = "grounds:edition" - /** The only value written today. A Java player carries no property rather than a value. */ + /** Authenticated Floodgate classification. */ const val BEDROCK = "bedrock" + /** Explicit Java classification written by proxies where Floodgate is absent or says no. */ + const val JAVA = "java" + /** * The property list to write, or null when [existing] already carries the marker. * @@ -71,5 +89,21 @@ class EditionStampListener(private val floodgate: FloodgateLookup, private val l fun withEditionProperty(existing: List): List? = if (existing.any { it.name == PROPERTY }) null else existing + GameProfile.Property(PROPERTY, BEDROCK, "") + + /** + * Replaces all untrusted edition markers with one authenticated [value]. A null value is an + * unknown classification and therefore removes markers without adding one. + */ + fun withEditionProperty( + existing: List, + value: String?, + ): List { + require(value == null || value == BEDROCK || value == JAVA) { + "Unsupported edition marker: $value" + } + val withoutEdition = existing.filterNot { it.name == PROPERTY } + return if (value == null) withoutEdition + else withoutEdition + GameProfile.Property(PROPERTY, value, "") + } } } diff --git a/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt b/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt index d081056..6cb57ac 100644 --- a/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt @@ -4,11 +4,9 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.velocitypowered.api.command.BrigadierCommand import com.velocitypowered.api.command.CommandSource import com.velocitypowered.api.proxy.Player -import gg.grounds.config.MessagesConfig -import net.kyori.adventure.text.Component +import gg.grounds.PlayerMessage +import gg.grounds.i18n.Translations import net.kyori.adventure.text.event.ClickEvent -import net.kyori.adventure.text.format.NamedTextColor -import net.kyori.adventure.text.format.TextDecoration import org.slf4j.Logger /** @@ -21,26 +19,26 @@ import org.slf4j.Logger */ object LinkCommand { - fun create( - client: ForgeLinkClient, - messages: MessagesConfig, - logger: Logger, - ): BrigadierCommand { + fun create(client: ForgeLinkClient, messages: Translations, logger: Logger): BrigadierCommand { val node = LiteralArgumentBuilder.literal("link").executes { ctx -> val player = ctx.source as? Player if (player == null) { - ctx.source.sendMessage( - Component.text(messages.linkPlayersOnly, NamedTextColor.RED) - ) + messages.send(ctx.source, PlayerMessage.LINK_PLAYERS_ONLY) return@executes 1 } - player.sendMessage(Component.text(messages.linkWorking, NamedTextColor.GRAY)) + messages.send(player, PlayerMessage.LINK_WORKING) client .startLink(player.uniqueId) - .thenAccept { url -> player.sendMessage(linkMessage(url, messages)) } + .thenAccept { url -> + val link = + messages + .render(PlayerMessage.LINK_CLICK, player) + .clickEvent(ClickEvent.openUrl(url)) + messages.send(player, PlayerMessage.LINK_PROMPT, "link" to link) + } .exceptionally { err -> // The player gets a flat "couldn't do it"; the reason is ours to debug. logger.warn( @@ -48,7 +46,7 @@ object LinkCommand { player.uniqueId, err, ) - player.sendMessage(Component.text(messages.linkFailed, NamedTextColor.RED)) + messages.send(player, PlayerMessage.LINK_FAILED) null } 1 @@ -59,29 +57,23 @@ object LinkCommand { fun createUnlink( client: ForgeLinkClient, - messages: MessagesConfig, + messages: Translations, logger: Logger, ): BrigadierCommand { val node = LiteralArgumentBuilder.literal("unlink").executes { ctx -> val player = ctx.source as? Player if (player == null) { - ctx.source.sendMessage( - Component.text(messages.linkPlayersOnly, NamedTextColor.RED) - ) + messages.send(ctx.source, PlayerMessage.LINK_PLAYERS_ONLY) return@executes 1 } client .unlink(player.uniqueId) - .thenRun { - player.sendMessage( - Component.text(messages.unlinkDone, NamedTextColor.GREEN) - ) - } + .thenRun { messages.send(player, PlayerMessage.UNLINK_DONE) } .exceptionally { err -> logger.warn("Failed to unlink (player={})", player.uniqueId, err) - player.sendMessage(Component.text(messages.linkFailed, NamedTextColor.RED)) + messages.send(player, PlayerMessage.LINK_FAILED) null } 1 @@ -89,17 +81,4 @@ object LinkCommand { return BrigadierCommand(node.build()) } - - /** - * The URL is long and single-use, so it is attached as a click target rather than pasted into - * chat — a wrapped URL that the player has to retype by hand is not a link flow. - */ - private fun linkMessage(url: String, messages: MessagesConfig): Component = - Component.text(messages.linkPrompt, NamedTextColor.YELLOW) - .append(Component.space()) - .append( - Component.text(messages.linkClickHere, NamedTextColor.AQUA) - .decorate(TextDecoration.UNDERLINED) - .clickEvent(ClickEvent.openUrl(url)) - ) } diff --git a/velocity/src/main/kotlin/gg/grounds/listener/LoginMessageRenderer.kt b/velocity/src/main/kotlin/gg/grounds/listener/LoginMessageRenderer.kt new file mode 100644 index 0000000..dd321f4 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/listener/LoginMessageRenderer.kt @@ -0,0 +1,15 @@ +package gg.grounds.listener + +import gg.grounds.PlayerMessage +import gg.grounds.i18n.Translations +import java.util.Locale +import net.kyori.adventure.text.Component + +/** PreLogin precedes client locale negotiation; use the source language until a locale is known. */ +internal object LoginMessageRenderer { + private val messages = + Translations.forBundle("gg.grounds.player.messages", javaClass.classLoader) + + fun render(key: PlayerMessage, override: String?, locale: Locale = Locale.ENGLISH): Component = + if (override != null) Component.text(override) else messages.render(key, locale) +} diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt index 7643c80..4cd5d72 100644 --- a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt @@ -8,6 +8,7 @@ import com.velocitypowered.api.event.connection.PreLoginEvent import com.velocitypowered.api.event.player.ServerConnectedEvent import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.util.UuidUtils +import gg.grounds.PlayerMessage import gg.grounds.config.MessagesConfig import gg.grounds.player.presence.PlayerLoginResult import gg.grounds.player.presence.PlayerLogoutResult @@ -58,7 +59,13 @@ class PlayerConnectionListener( playerId, name, ) - deny(event, messages.alreadyOnline) + deny( + event, + LoginMessageRenderer.render( + PlayerMessage.LOGIN_ALREADY_ONLINE, + messages.alreadyOnline, + ), + ) } is PlayerLoginResult.Invalid -> { logger.warn( @@ -67,7 +74,13 @@ class PlayerConnectionListener( name, result.message, ) - deny(event, messages.invalidRequest) + deny( + event, + LoginMessageRenderer.render( + PlayerMessage.LOGIN_INVALID_REQUEST, + messages.invalidRequest, + ), + ) } is PlayerLoginResult.Unavailable -> { logger.warn( @@ -76,7 +89,13 @@ class PlayerConnectionListener( name, result.message, ) - deny(event, messages.serviceUnavailable) + deny( + event, + LoginMessageRenderer.render( + PlayerMessage.LOGIN_SERVICE_UNAVAILABLE, + messages.serviceUnavailable, + ), + ) } is PlayerLoginResult.Error -> { logger.warn( @@ -85,7 +104,13 @@ class PlayerConnectionListener( name, result.message, ) - deny(event, messages.genericError) + deny( + event, + LoginMessageRenderer.render( + PlayerMessage.LOGIN_ERROR, + messages.genericError, + ), + ) } } } @@ -130,8 +155,8 @@ class PlayerConnectionListener( } } - private fun deny(event: PreLoginEvent, message: String) { - event.result = PreLoginEvent.PreLoginComponentResult.denied(Component.text(message)) + private fun deny(event: PreLoginEvent, message: Component) { + event.result = PreLoginEvent.PreLoginComponentResult.denied(message) } /** diff --git a/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt b/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt index ce5fc23..69241d8 100644 --- a/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt @@ -6,12 +6,12 @@ import com.mojang.brigadier.builder.RequiredArgumentBuilder import com.velocitypowered.api.command.BrigadierCommand import com.velocitypowered.api.command.CommandSource import com.velocitypowered.api.proxy.Player +import gg.grounds.PlayerMessage +import gg.grounds.i18n.Translations import gg.grounds.locale.PlayerLocaleCache import gg.grounds.locale.SupportedLanguages import gg.grounds.presence.PlayerPresenceService import java.util.concurrent.CompletableFuture -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor import org.slf4j.Logger /** @@ -28,11 +28,12 @@ object LangCommand { cache: PlayerLocaleCache, presence: PlayerPresenceService, logger: Logger, + messages: Translations, ): BrigadierCommand { val node = LiteralArgumentBuilder.literal("lang") .executes { ctx -> - ctx.source.sendMessage(usage()) + messages.send(ctx.source, PlayerMessage.LANG_USAGE, "available" to available()) 1 } .then( @@ -47,31 +48,24 @@ object LangCommand { .executes { ctx -> val player = ctx.source as? Player if (player == null) { - ctx.source.sendMessage( - Component.text( - "Only players can use this command.", - NamedTextColor.RED, - ) - ) + messages.send(ctx.source, PlayerMessage.LANG_PLAYERS_ONLY) return@executes 1 } val code = StringArgumentType.getString(ctx, "code").lowercase() val locale = SupportedLanguages.parse(code) if (locale == null) { - player.sendMessage( - Component.text( - "Unknown language '$code'. Available: ${available()}", - NamedTextColor.RED, - ) + messages.send( + player, + PlayerMessage.LANG_UNKNOWN, + "code" to code, + "available" to available(), ) return@executes 1 } cache.choose(player, locale) - player.sendMessage( - Component.text("Language set to $code.", NamedTextColor.GREEN) - ) + messages.send(player, PlayerMessage.LANG_SET, "code" to code) CompletableFuture.runAsync { if (!presence.setLocale(player.uniqueId, code)) { logger.warn( @@ -88,8 +82,5 @@ object LangCommand { return BrigadierCommand(node.build()) } - private fun usage(): Component = - Component.text("Usage: /lang . Available: ${available()}", NamedTextColor.YELLOW) - private fun available(): String = SupportedLanguages.ALL.keys.joinToString(", ") } diff --git a/velocity/src/main/kotlin/gg/grounds/presentation/VelocitySystemMessages.kt b/velocity/src/main/kotlin/gg/grounds/presentation/VelocitySystemMessages.kt new file mode 100644 index 0000000..f7b4d16 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/presentation/VelocitySystemMessages.kt @@ -0,0 +1,21 @@ +package gg.grounds.presentation + +import com.velocitypowered.api.proxy.Player +import gg.grounds.i18n.SystemMessageRenderer +import gg.grounds.proxy.api.PlayerChatAppearanceQuery +import gg.grounds.proxy.api.ProxyServiceRegistry + +/** Resolve readiness at delivery time: plugins never cache a player's pack state. */ +internal object VelocitySystemMessages { + val renderer = + SystemMessageRenderer.withLogoReadiness { audience -> + val player = audience as? Player + player != null && + runCatching { + ProxyServiceRegistry.get(PlayerChatAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.chatFontReady == true + } + .getOrDefault(false) + } +} diff --git a/velocity/src/main/resources/gg/grounds/player/messages.properties b/velocity/src/main/resources/gg/grounds/player/messages.properties new file mode 100644 index 0000000..498f8b6 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/player/messages.properties @@ -0,0 +1,15 @@ +lang.usage=Usage: /lang \\. Available: +lang.players-only=Only players can use this command. +lang.unknown=Unknown language ''. Available: +lang.set=Language set to . +link.players-only=Only players can link a Microsoft account. +link.working=Preparing your Microsoft sign-in... +link.prompt=Sign in with Microsoft to find friends who play here: +link.click=[Click to connect] +link.failed=Could not start the link right now. Please try again. +unlink.done=Your Microsoft account has been disconnected. + +login.service-unavailable=Login service unavailable +login.already-online=You are already online. +login.invalid-request=Invalid login request. +login.error=Unable to create player session. diff --git a/velocity/src/main/resources/gg/grounds/player/messages_de.properties b/velocity/src/main/resources/gg/grounds/player/messages_de.properties new file mode 100644 index 0000000..23a4c04 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/player/messages_de.properties @@ -0,0 +1,15 @@ +lang.usage=Benutzung: /lang \\. Verfügbar: +lang.players-only=Nur Spieler können diesen Befehl benutzen. +lang.unknown=Unbekannte Sprache ''. Verfügbar: +lang.set=Sprache auf gesetzt. +link.players-only=Nur Spieler können ein Microsoft-Konto verknüpfen. +link.working=Deine Microsoft-Anmeldung wird vorbereitet... +link.prompt=Melde dich bei Microsoft an, um Freunde zu finden, die hier spielen: +link.click=[Zum Verbinden klicken] +link.failed=Die Verknüpfung konnte gerade nicht gestartet werden. Bitte versuche es erneut. +unlink.done=Die Verbindung zu deinem Microsoft-Konto wurde getrennt. + +login.service-unavailable=Der Anmeldedienst ist nicht verfügbar. +login.already-online=Du bist bereits online. +login.invalid-request=Ungültige Anmeldeanfrage. +login.error=Deine Spielersitzung konnte nicht erstellt werden. diff --git a/velocity/src/main/resources/gg/grounds/player/messages_es.properties b/velocity/src/main/resources/gg/grounds/player/messages_es.properties new file mode 100644 index 0000000..c8b2381 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/player/messages_es.properties @@ -0,0 +1,15 @@ +lang.usage=Uso: /lang \\. Disponibles: +lang.players-only=Solo los jugadores pueden usar este comando. +lang.unknown=Idioma desconocido ''. Disponibles: +lang.set=Idioma cambiado a . +link.players-only=Solo los jugadores pueden vincular una cuenta de Microsoft. +link.working=Preparando tu inicio de sesión de Microsoft... +link.prompt=Inicia sesión con Microsoft para encontrar amigos que juegan aquí: +link.click=[Haz clic para conectar] +link.failed=No se pudo iniciar la vinculación. Inténtalo de nuevo. +unlink.done=Tu cuenta de Microsoft se ha desconectado. + +login.service-unavailable=El servicio de acceso no está disponible. +login.already-online=Ya estás conectado. +login.invalid-request=Solicitud de acceso no válida. +login.error=No se pudo crear tu sesión. diff --git a/velocity/src/main/resources/gg/grounds/player/messages_fr.properties b/velocity/src/main/resources/gg/grounds/player/messages_fr.properties new file mode 100644 index 0000000..8c1b0aa --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/player/messages_fr.properties @@ -0,0 +1,15 @@ +lang.usage=Utilisation : /lang \\. Disponibles : +lang.players-only=Seuls les joueurs peuvent utiliser cette commande. +lang.unknown=Langue inconnue ''. Disponibles : +lang.set=Langue définie sur . +link.players-only=Seuls les joueurs peuvent associer un compte Microsoft. +link.working=Préparation de votre connexion Microsoft... +link.prompt=Connectez-vous avec Microsoft pour trouver des amis qui jouent ici : +link.click=[Cliquer pour connecter] +link.failed=Impossible de démarrer l'association. Veuillez réessayer. +unlink.done=Votre compte Microsoft a été dissocié. + +login.service-unavailable=Le service de connexion est indisponible. +login.already-online=Tu es déjà en ligne. +login.invalid-request=Demande de connexion invalide. +login.error=Impossible de créer ta session. diff --git a/velocity/src/main/resources/messages.yml b/velocity/src/main/resources/messages.yml index ce7ef24..46e3126 100644 --- a/velocity/src/main/resources/messages.yml +++ b/velocity/src/main/resources/messages.yml @@ -1,4 +1,5 @@ -serviceUnavailable: "Login service unavailable" -alreadyOnline: "You are already online." -invalidRequest: "Invalid login request." -genericError: "Unable to create player session." +# Optional literal overrides. Leave null to use the i18n source-language fallback before login. +serviceUnavailable: null +alreadyOnline: null +invalidRequest: null +genericError: null diff --git a/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt b/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt new file mode 100644 index 0000000..a3b569c --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt @@ -0,0 +1,45 @@ +package gg.grounds + +import gg.grounds.i18n.BundleCheck +import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.Translations +import java.util.Locale +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PlayerMessagesTest { + private val messages = + Translations.forBundle("gg.grounds.player.messages", javaClass.classLoader) + private val plain = PlainTextComponentSerializer.plainText() + + @Test + fun `language errors are localized and player input remains literal`() { + val rendered = + messages.render( + PlayerMessage.LANG_UNKNOWN, + Locale.GERMAN, + "code" to "xx", + "available" to "en, de, es, fr", + ) + + assertEquals( + "Unbekannte Sprache 'xx'. Verfügbar: en, de, es, fr", + plain.serialize(rendered), + ) + assertEquals(SystemCategory.ERROR, PlayerMessage.LANG_UNKNOWN.category) + } + + @Test + fun `English and German bundles cover every player message`() { + assertEquals( + emptyList(), + BundleCheck.problems( + "gg.grounds.player.messages", + javaClass.classLoader, + PlayerMessage.entries, + translations = listOf(Locale.GERMAN), + ), + ) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt index 7b06dc5..8992209 100644 --- a/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt @@ -2,14 +2,25 @@ package gg.grounds.edition import com.velocitypowered.api.util.GameProfile import gg.grounds.edition.listener.EditionStampListener +import java.util.UUID import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.slf4j.helpers.NOPLogger class EditionStampListenerTest { + @BeforeEach + fun resetFakeFloodgate() { + FakeFloodgateApi.bedrock = false + FakeFloodgateApi.fail = false + } + private fun textures() = GameProfile.Property("textures", "skin-payload", "skin-signature") @Test @@ -54,12 +65,119 @@ class EditionStampListenerTest { ) } - /** - * Two of the three proxies have no Floodgate, and that is the ordinary case rather than a - * misconfiguration — the listener is simply not registered there. - */ @Test - fun theLookupIsAbsentWithoutFloodgate() { - assertNull(FloodgateLookup.create(NOPLogger.NOP_LOGGER)) + fun explicitJavaStampReplacesAConflictingMarkerAndKeepsTextures() { + val existing = + listOf( + textures(), + GameProfile.Property( + EditionStampListener.PROPERTY, + EditionStampListener.BEDROCK, + "", + ), + ) + + val stamped = EditionStampListener.withEditionProperty(existing, EditionStampListener.JAVA) + + assertEquals(listOf("textures", EditionStampListener.PROPERTY), stamped.map { it.name }) + assertEquals(EditionStampListener.JAVA, stamped.last().value) + } + + @Test + fun authoritativeBedrockStampCollapsesDuplicateAndConflictingMarkers() { + val existing = + listOf( + GameProfile.Property(EditionStampListener.PROPERTY, EditionStampListener.JAVA, ""), + textures(), + GameProfile.Property( + EditionStampListener.PROPERTY, + EditionStampListener.BEDROCK, + "", + ), + ) + + val stamped = + EditionStampListener.withEditionProperty(existing, EditionStampListener.BEDROCK) + + assertEquals(listOf("textures", EditionStampListener.PROPERTY), stamped.map { it.name }) + assertEquals(EditionStampListener.BEDROCK, stamped.last().value) + } + + @Test + fun unknownClassificationRemovesAnUnverifiedEditionMarker() { + val existing = + listOf( + textures(), + GameProfile.Property(EditionStampListener.PROPERTY, EditionStampListener.JAVA, ""), + ) + + val stamped = EditionStampListener.withEditionProperty(existing, null) + assertEquals(1, stamped.size) + assertSame(existing.first(), stamped.single()) + } + + /** Missing Floodgate is positive evidence that every connection on this proxy is Java. */ + @Test + fun missingFloodgateIsKnownJava() { + val lookup = + FloodgateLookup.create(NOPLogger.NOP_LOGGER) { + throw ClassNotFoundException("Floodgate intentionally absent") + } + + assertEquals(false, lookup.classify(PLAYER_ID)) + assertFalse(lookup.isBedrock(PLAYER_ID)) + } + + @Test + fun linkedBedrockAndJavaPlayersUseFloodgateRatherThanUuidShape() { + FakeFloodgateApi.bedrock = true + val lookup = FloodgateLookup.create(NOPLogger.NOP_LOGGER) { FakeFloodgateApi::class.java } + + assertEquals(true, lookup.classify(PLAYER_ID)) + assertTrue(lookup.isBedrock(PLAYER_ID)) + + FakeFloodgateApi.bedrock = false + assertEquals(false, lookup.classify(PLAYER_ID)) + assertFalse(lookup.isBedrock(PLAYER_ID)) + } + + @Test + fun brokenFloodgateShapeIsUnknownRatherThanJava() { + val lookup = FloodgateLookup.create(NOPLogger.NOP_LOGGER) { BadFloodgateApi::class.java } + + assertNull(lookup.classify(PLAYER_ID)) + assertFalse(lookup.isBedrock(PLAYER_ID)) + } + + @Test + fun floodgateInvocationFailureIsUnknownButAntiCheatContractRemainsFalse() { + FakeFloodgateApi.fail = true + val lookup = FloodgateLookup.create(NOPLogger.NOP_LOGGER) { FakeFloodgateApi::class.java } + + assertNull(lookup.classify(PLAYER_ID)) + assertFalse(lookup.isBedrock(PLAYER_ID)) + + FakeFloodgateApi.fail = false + } + + companion object { + private val PLAYER_ID = UUID.fromString("67e55044-10b1-426f-9247-bb680e5fe0c8") } } + +class FakeFloodgateApi { + fun isFloodgatePlayer(playerId: UUID): Boolean { + require(playerId == UUID.fromString("67e55044-10b1-426f-9247-bb680e5fe0c8")) + if (fail) throw IllegalStateException("Floodgate unavailable") + return bedrock + } + + companion object { + var bedrock: Boolean = false + var fail: Boolean = false + + @JvmStatic fun getInstance(): FakeFloodgateApi = FakeFloodgateApi() + } +} + +class BadFloodgateApi diff --git a/velocity/src/test/kotlin/gg/grounds/listener/LoginMessageRendererTest.kt b/velocity/src/test/kotlin/gg/grounds/listener/LoginMessageRendererTest.kt new file mode 100644 index 0000000..47e7c41 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/listener/LoginMessageRendererTest.kt @@ -0,0 +1,36 @@ +package gg.grounds.listener + +import gg.grounds.PlayerMessage +import java.util.Locale +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LoginMessageRendererTest { + private val plain = PlainTextComponentSerializer.plainText() + + @Test + fun `default login denial uses the source bundle without a chat prefix`() { + assertEquals( + "You are already online.", + plain.serialize(LoginMessageRenderer.render(PlayerMessage.LOGIN_ALREADY_ONLINE, null)), + ) + assertEquals( + "Du bist bereits online.", + plain.serialize( + LoginMessageRenderer.render(PlayerMessage.LOGIN_ALREADY_ONLINE, null, Locale.GERMAN) + ), + ) + } + + @Test + fun `explicit operator override remains literal`() { + val text = "Custom denial" + assertEquals( + text, + plain.serialize( + LoginMessageRenderer.render(PlayerMessage.LOGIN_ERROR, text, Locale.GERMAN) + ), + ) + } +} From 7f053718fd1e89cae8a770860fc64b1cfbc14e68 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 15 Oct 2026 00:02:20 +0200 Subject: [PATCH 2/4] refactor(chat): separate message context from status level --- .../main/kotlin/gg/grounds/PlayerMessage.kt | 36 ++++++++++--------- .../kotlin/gg/grounds/PlayerMessagesTest.kt | 4 ++- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt b/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt index b4e8a5a..df0b12d 100644 --- a/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/PlayerMessage.kt @@ -1,22 +1,26 @@ package gg.grounds import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemLevel import gg.grounds.i18n.SystemMessageKey -enum class PlayerMessage(override val id: String, override val category: SystemCategory) : - SystemMessageKey { - LOGIN_SERVICE_UNAVAILABLE("login.service-unavailable", SystemCategory.ERROR), - LOGIN_ALREADY_ONLINE("login.already-online", SystemCategory.ERROR), - LOGIN_INVALID_REQUEST("login.invalid-request", SystemCategory.ERROR), - LOGIN_ERROR("login.error", SystemCategory.ERROR), - LANG_USAGE("lang.usage", SystemCategory.WARNING), - LANG_PLAYERS_ONLY("lang.players-only", SystemCategory.ERROR), - LANG_UNKNOWN("lang.unknown", SystemCategory.ERROR), - LANG_SET("lang.set", SystemCategory.SUCCESS), - LINK_PLAYERS_ONLY("link.players-only", SystemCategory.ERROR), - LINK_WORKING("link.working", SystemCategory.INFO), - LINK_PROMPT("link.prompt", SystemCategory.INFO), - LINK_CLICK("link.click", SystemCategory.INFO), - LINK_FAILED("link.failed", SystemCategory.ERROR), - UNLINK_DONE("unlink.done", SystemCategory.SUCCESS), +enum class PlayerMessage( + override val id: String, + override val category: SystemCategory = SystemCategory.GENERAL, + override val level: SystemLevel = SystemLevel.INFO, +) : SystemMessageKey { + LOGIN_SERVICE_UNAVAILABLE("login.service-unavailable", level = SystemLevel.ERROR), + LOGIN_ALREADY_ONLINE("login.already-online", level = SystemLevel.ERROR), + LOGIN_INVALID_REQUEST("login.invalid-request", level = SystemLevel.ERROR), + LOGIN_ERROR("login.error", level = SystemLevel.ERROR), + LANG_USAGE("lang.usage", level = SystemLevel.WARNING), + LANG_PLAYERS_ONLY("lang.players-only", level = SystemLevel.ERROR), + LANG_UNKNOWN("lang.unknown", level = SystemLevel.ERROR), + LANG_SET("lang.set", level = SystemLevel.SUCCESS), + LINK_PLAYERS_ONLY("link.players-only", level = SystemLevel.ERROR), + LINK_WORKING("link.working"), + LINK_PROMPT("link.prompt"), + LINK_CLICK("link.click"), + LINK_FAILED("link.failed", level = SystemLevel.ERROR), + UNLINK_DONE("unlink.done", level = SystemLevel.SUCCESS), } diff --git a/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt b/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt index a3b569c..06f587f 100644 --- a/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/PlayerMessagesTest.kt @@ -2,6 +2,7 @@ package gg.grounds import gg.grounds.i18n.BundleCheck import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemLevel import gg.grounds.i18n.Translations import java.util.Locale import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer @@ -27,7 +28,8 @@ class PlayerMessagesTest { "Unbekannte Sprache 'xx'. Verfügbar: en, de, es, fr", plain.serialize(rendered), ) - assertEquals(SystemCategory.ERROR, PlayerMessage.LANG_UNKNOWN.category) + assertEquals(SystemCategory.GENERAL, PlayerMessage.LANG_UNKNOWN.category) + assertEquals(SystemLevel.ERROR, PlayerMessage.LANG_UNKNOWN.level) } @Test From 80b4a36c9131140aa3b4fba05cd1da34d1e825b7 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 15 Oct 2026 00:30:08 +0200 Subject: [PATCH 3/4] build(chat): consume published shared chat APIs --- velocity/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 97709f6..0e4da75 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -12,16 +12,16 @@ repositories { dependencies { implementation(project(":common")) - implementation("gg.grounds:library-i18n:0.3.0") + implementation("gg.grounds:library-i18n:0.4.0") // plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the // registry this plugin writes into would be a different class from the one chat/social read. - compileOnly("gg.grounds:plugin-proxy-api:0.5.0") + compileOnly("gg.grounds:plugin-proxy-api:2.6.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.2.2") implementation("tools.jackson.module:jackson-module-kotlin:3.2.2") // compileOnly above is not visible to tests; PlayerSessionQueryImplTest needs the interface's // types. - testImplementation("gg.grounds:plugin-proxy-api:0.5.0") + testImplementation("gg.grounds:plugin-proxy-api:2.6.0") // Same reason: the conventions plugin puts velocity-api on compileOnly, and // EditionStampListenerTest builds a GameProfile.Property. testImplementation("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT") From f9d0d4e7cd2c05090b630b8e52aee7f37bcd38cc Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sat, 17 Oct 2026 13:17:15 +0200 Subject: [PATCH 4/4] build: use library-i18n 0.4.3 --- velocity/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 9010271..6c759ba 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -12,16 +12,16 @@ repositories { dependencies { implementation(project(":common")) - implementation("gg.grounds:library-i18n:0.4.0") + implementation("gg.grounds:library-i18n:0.4.3") // plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the // registry this plugin writes into would be a different class from the one chat/social read. - compileOnly("gg.grounds:plugin-proxy-api:0.5.0") + compileOnly("gg.grounds:plugin-proxy-api:2.6.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.2.3") implementation("tools.jackson.module:jackson-module-kotlin:3.2.3") // compileOnly above is not visible to tests; PlayerSessionQueryImplTest needs the interface's // types. - testImplementation("gg.grounds:plugin-proxy-api:0.5.0") + testImplementation("gg.grounds:plugin-proxy-api:2.6.0") // Same reason: the conventions plugin puts velocity-api on compileOnly, and // EditionStampListenerTest builds a GameProfile.Property. testImplementation("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")