From ac007679c20061df3bcb7dc834c2ecb82a1d21ce Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 30 Jul 2026 17:30:46 +0200 Subject: [PATCH] feat: /lang command with per-player, persisted language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /lang [] lets a player pick their interface language from the set the network ships bundles for (en, de, fr, es). The choice is cached in memory for the render path, published to localized plugins through the ProxyServiceRegistry (PlayerLocaleQuery), and persisted via service-player so it survives reconnects — loaded back into the cache on join. - GrpcPlayerPresenceClient: getLocale/setLocale on the existing authed stub - PlayerLocaleCache + LocaleConnectionListener (PostLogin load, Disconnect evict) - PlayerLocaleQueryImpl registered into the registry, mirroring PlayerSessionQuery - LangCommand: cache updated first (felt on the next message), durable write off-thread Bumps library-grpc-contracts-player 0.6.0 + plugin-proxy-api 0.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LQVxNNbkovNsRstv82kQSy --- common/build.gradle.kts | 2 +- .../presence/GrpcPlayerPresenceClient.kt | 34 +++++++ velocity/build.gradle.kts | 4 +- .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 22 +++++ .../gg/grounds/locale/PlayerLocaleCache.kt | 29 ++++++ .../grounds/locale/PlayerLocaleQueryImpl.kt | 14 +++ .../gg/grounds/locale/SupportedLanguages.kt | 26 +++++ .../gg/grounds/locale/commands/LangCommand.kt | 95 +++++++++++++++++++ .../listener/LocaleConnectionListener.kt | 35 +++++++ .../grounds/presence/PlayerPresenceService.kt | 18 ++++ 10 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleCache.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleQueryImpl.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/locale/SupportedLanguages.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/locale/listener/LocaleConnectionListener.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 7e78ee3..4ee5ea7 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -11,7 +11,7 @@ repositories { } dependencies { - protobuf("gg.grounds:library-grpc-contracts-player:0.5.0") + protobuf("gg.grounds:library-grpc-contracts-player:0.6.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index 3815bd9..423a996 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -4,6 +4,7 @@ import gg.grounds.grpc.player.CountPlayersByProxyReply import gg.grounds.grpc.player.CountPlayersByProxyRequest import gg.grounds.grpc.player.CountPlayersByServerReply import gg.grounds.grpc.player.CountPlayersByServerRequest +import gg.grounds.grpc.player.GetPlayerLocaleRequest import gg.grounds.grpc.player.GetPlayerSessionRequest import gg.grounds.grpc.player.PlayerHeartbeatBatchReply import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest @@ -13,6 +14,7 @@ import gg.grounds.grpc.player.PlayerLogoutRequest import gg.grounds.grpc.player.PlayerPresenceServiceGrpc import gg.grounds.grpc.player.PlayerSessionInfo import gg.grounds.grpc.player.ResolvePlayerNameRequest +import gg.grounds.grpc.player.SetPlayerLocaleRequest import gg.grounds.grpc.player.SuggestPlayerNamesRequest import gg.grounds.grpc.player.UpdatePlayerServerRequest import io.grpc.ManagedChannel @@ -183,6 +185,38 @@ private constructor( } } + /** The player's stored language tag, or null when they have chosen none. Never throws. */ + fun getLocale(playerId: UUID): String? { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .getPlayerLocale( + GetPlayerLocaleRequest.newBuilder().setPlayerId(playerId.toString()).build() + ) + .locale + .ifEmpty { null } + } catch (e: RuntimeException) { + null + } + } + + /** Persists (or, with a blank tag, clears) the player's language. Never throws. */ + fun setLocale(playerId: UUID, locale: String): Boolean { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .setPlayerLocale( + SetPlayerLocaleRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setLocale(locale) + .build() + ) + .updated + } catch (e: RuntimeException) { + false + } + } + companion object { fun create(target: String): GrpcPlayerPresenceClient { val channelBuilder = ManagedChannelBuilder.forTarget(target) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index aca2526..207891c 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,14 +14,14 @@ dependencies { implementation(project(":common")) // 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.3.0") + compileOnly("gg.grounds:plugin-proxy-api:0.5.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.4") implementation("tools.jackson.module:jackson-module-kotlin:3.0.4") implementation("io.grpc:grpc-netty-shaded:1.78.0") // compileOnly above is not visible to tests; PlayerSessionQueryImplTest needs the interface's // types. - testImplementation("gg.grounds:plugin-proxy-api:0.3.0") + testImplementation("gg.grounds:plugin-proxy-api:0.5.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4") diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index a864e2a..77c9e4c 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -13,9 +13,14 @@ import gg.grounds.config.MessagesConfigLoader import gg.grounds.link.ForgeLinkClient import gg.grounds.link.LinkCommand import gg.grounds.listener.PlayerConnectionListener +import gg.grounds.locale.PlayerLocaleCache +import gg.grounds.locale.PlayerLocaleQueryImpl +import gg.grounds.locale.commands.LangCommand +import gg.grounds.locale.listener.LocaleConnectionListener import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService import gg.grounds.presence.PlayerSessionQueryImpl +import gg.grounds.proxy.api.PlayerLocaleQuery import gg.grounds.proxy.api.PlayerSessionQuery import gg.grounds.proxy.api.ProxyServiceRegistry import io.grpc.LoadBalancerRegistry @@ -43,6 +48,7 @@ constructor( @param:DataDirectory private val dataDirectory: Path, ) { private val playerPresenceService = PlayerPresenceService() + private val playerLocaleCache = PlayerLocaleCache() private val heartbeatScheduler = PlayerHeartbeatScheduler(this, proxy, logger, playerPresenceService) @@ -77,6 +83,20 @@ constructor( PlayerSessionQueryImpl(playerPresenceService), ) + // Per-player language: seed the cache on join, publish it to localized plugins (social), + // and let the player change it with /lang. + proxy.eventManager.register( + this, + LocaleConnectionListener(playerLocaleCache, playerPresenceService), + ) + ProxyServiceRegistry.register( + PlayerLocaleQuery::class.java, + PlayerLocaleQueryImpl(playerLocaleCache), + ) + proxy.commandManager.register( + LangCommand.create(playerLocaleCache, playerPresenceService, logger) + ) + registerLinkCommands(messages) heartbeatScheduler.start() @@ -113,6 +133,8 @@ constructor( @Subscribe fun onShutdown(event: ProxyShutdownEvent) { ProxyServiceRegistry.unregister(PlayerSessionQuery::class.java) + ProxyServiceRegistry.unregister(PlayerLocaleQuery::class.java) + playerLocaleCache.clear() heartbeatScheduler.stop() playerPresenceService.close() } diff --git a/velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleCache.kt b/velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleCache.kt new file mode 100644 index 0000000..2ed6fba --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleCache.kt @@ -0,0 +1,29 @@ +package gg.grounds.locale + +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Each online player's chosen language, held in memory so the render path (every message, every + * tick) never makes a network call. Loaded from service-player on join, updated by `/lang`, dropped + * on disconnect. A player who is absent here has set no preference — the caller uses the client's + * announced locale. + */ +class PlayerLocaleCache { + private val cache = ConcurrentHashMap() + + fun get(playerId: UUID): Locale? = cache[playerId] + + fun set(playerId: UUID, locale: Locale) { + cache[playerId] = locale + } + + fun remove(playerId: UUID) { + cache.remove(playerId) + } + + fun clear() { + cache.clear() + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleQueryImpl.kt b/velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleQueryImpl.kt new file mode 100644 index 0000000..5340570 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleQueryImpl.kt @@ -0,0 +1,14 @@ +package gg.grounds.locale + +import gg.grounds.proxy.api.PlayerLocaleQuery +import java.util.Locale +import java.util.UUID + +/** + * Publishes the per-player language cache to other plugins through the ProxyServiceRegistry, so a + * localized plugin (plugin-social today) can resolve a message in the player's chosen language + * without knowing anything about how it is stored. + */ +class PlayerLocaleQueryImpl(private val cache: PlayerLocaleCache) : PlayerLocaleQuery { + override fun localeOf(playerId: UUID): Locale? = cache.get(playerId) +} diff --git a/velocity/src/main/kotlin/gg/grounds/locale/SupportedLanguages.kt b/velocity/src/main/kotlin/gg/grounds/locale/SupportedLanguages.kt new file mode 100644 index 0000000..b7c771b --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/locale/SupportedLanguages.kt @@ -0,0 +1,26 @@ +package gg.grounds.locale + +import java.util.Locale + +/** + * The languages `/lang` will accept — the ones the network's plugins actually ship bundles for. A + * tag outside this set is rejected by the command rather than stored, so a player cannot pick a + * language that would only ever render as the English fallback. + * + * English is included on purpose: it lets a player on a German client override back to English, + * which resolves to the untranslated source bundle. + * + * Ordered (LinkedHashMap) so the command lists them the same way every time. + */ +object SupportedLanguages { + val ALL: Map = + linkedMapOf( + "en" to Locale.ENGLISH, + "de" to Locale.GERMAN, + "fr" to Locale.FRENCH, + "es" to Locale.forLanguageTag("es"), + ) + + /** The [Locale] for a supported tag (case-insensitive), or null if it is not one we ship. */ + fun parse(tag: String): Locale? = ALL[tag.lowercase()] +} diff --git a/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt b/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt new file mode 100644 index 0000000..5e698d5 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt @@ -0,0 +1,95 @@ +package gg.grounds.locale.commands + +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.builder.LiteralArgumentBuilder +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.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 + +/** + * `/lang` — pick the language messages are shown in. + * + * `/lang` alone lists the choices; `/lang ` sets one. The cache is updated first, so the + * change is felt on the next message, and the durable write to service-player runs off-thread + * afterwards — a slow or failed database round-trip must not freeze the command, and the choice + * still holds for the session even if the write is lost. + */ +object LangCommand { + + fun create( + cache: PlayerLocaleCache, + presence: PlayerPresenceService, + logger: Logger, + ): BrigadierCommand { + val node = + LiteralArgumentBuilder.literal("lang") + .executes { ctx -> + ctx.source.sendMessage(usage()) + 1 + } + .then( + RequiredArgumentBuilder.argument( + "code", + StringArgumentType.word(), + ) + .suggests { _, builder -> + SupportedLanguages.ALL.keys.forEach(builder::suggest) + builder.buildFuture() + } + .executes { ctx -> + val player = ctx.source as? Player + if (player == null) { + ctx.source.sendMessage( + Component.text( + "Only players can use this command.", + NamedTextColor.RED, + ) + ) + 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, + ) + ) + return@executes 1 + } + + cache.set(player.uniqueId, locale) + player.sendMessage( + Component.text("Language set to $code.", NamedTextColor.GREEN) + ) + CompletableFuture.runAsync { + if (!presence.setLocale(player.uniqueId, code)) { + logger.warn( + "Failed to persist locale (player={}, code={})", + player.uniqueId, + code, + ) + } + } + 1 + } + ) + + 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/locale/listener/LocaleConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/locale/listener/LocaleConnectionListener.kt new file mode 100644 index 0000000..1d85b28 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/locale/listener/LocaleConnectionListener.kt @@ -0,0 +1,35 @@ +package gg.grounds.locale.listener + +import com.velocitypowered.api.event.EventTask +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PostLoginEvent +import gg.grounds.locale.PlayerLocaleCache +import gg.grounds.locale.SupportedLanguages +import gg.grounds.presence.PlayerPresenceService + +/** + * Seeds the [PlayerLocaleCache] from the player's stored preference on join, and clears it on + * disconnect. Both run off the event thread — the join path makes a gRPC call to service-player, + * and a language lookup must never hold up a login (a failure just leaves the client locale in + * effect). + */ +class LocaleConnectionListener( + private val cache: PlayerLocaleCache, + private val presence: PlayerPresenceService, +) { + @Subscribe + fun onPostLogin(event: PostLoginEvent): EventTask { + val playerId = event.player.uniqueId + return EventTask.async { + val tag = presence.getLocale(playerId) ?: return@async + SupportedLanguages.parse(tag)?.let { cache.set(playerId, it) } + } + } + + @Subscribe + fun onDisconnect(event: DisconnectEvent): EventTask { + val playerId = event.player.uniqueId + return EventTask.async { cache.remove(playerId) } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index 3b8fe5a..70e7e2d 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -110,6 +110,24 @@ class PlayerPresenceService : AutoCloseable { } } + /** The player's stored language tag, or null when none is set. Never throws. */ + fun getLocale(playerId: UUID): String? { + return try { + client.getLocale(playerId) + } catch (e: RuntimeException) { + null + } + } + + /** Persists (or clears, with a blank tag) the player's language. Never throws. */ + fun setLocale(playerId: UUID, locale: String): Boolean { + return try { + client.setLocale(playerId, locale) + } catch (e: RuntimeException) { + false + } + } + override fun close() { if (this::client.isInitialized) { client.close()