Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down
29 changes: 29 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/locale/PlayerLocaleCache.kt
Original file line number Diff line number Diff line change
@@ -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<UUID, Locale>()

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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
26 changes: 26 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/locale/SupportedLanguages.kt
Original file line number Diff line number Diff line change
@@ -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<String, Locale> =
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()]
}
95 changes: 95 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/locale/commands/LangCommand.kt
Original file line number Diff line number Diff line change
@@ -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 <code>` 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<CommandSource>("lang")
.executes { ctx ->
ctx.source.sendMessage(usage())
1
}
.then(
RequiredArgumentBuilder.argument<CommandSource, String>(
"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 <code>. Available: ${available()}", NamedTextColor.YELLOW)

private fun available(): String = SupportedLanguages.ALL.keys.joinToString(", ")
}
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down