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()