diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index cd39a46..60effe7 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -4,14 +4,15 @@ dependencies { implementation(platform("gg.grounds:grounds-dependencies:0.1.0")) implementation(project(":common")) + implementation("gg.grounds:library-i18n:0.4.3") implementation("io.kubernetes:client-java") // Never shaded: the ProxyServiceRegistry we read from must be the very class plugin-proxy // loaded, or we would be reading a registry nobody writes to. 2.2.0 is the first version // carrying ServerDisplayQuery. - compileOnly("gg.grounds:plugin-proxy-api:2.2.0") + compileOnly("gg.grounds:plugin-proxy-api:2.6.0") - testImplementation("gg.grounds:plugin-proxy-api:2.2.0") + testImplementation("gg.grounds:plugin-proxy-api:2.6.0") testImplementation("com.velocitypowered:velocity-api") testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/velocity/src/main/kotlin/gg/grounds/AgonesMessage.kt b/velocity/src/main/kotlin/gg/grounds/AgonesMessage.kt new file mode 100644 index 0000000..7a262bc --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/AgonesMessage.kt @@ -0,0 +1,20 @@ +package gg.grounds + +import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemLevel +import gg.grounds.i18n.SystemMessageKey + +enum class AgonesMessage( + override val id: String, + override val category: SystemCategory = SystemCategory.GENERAL, + override val level: SystemLevel = SystemLevel.INFO, +) : SystemMessageKey { + NO_SERVERS("command.no-servers", level = SystemLevel.ERROR), + SERVER_HEADER("command.server-header"), + SERVER_LINE("command.server-line"), + SERVER_TOTAL("command.server-total"), + LOCAL_PLAYER_COUNTS("command.local-player-counts", level = SystemLevel.WARNING), + MOVING_TO_UPDATED_LOBBY("discovery.moving-to-updated-lobby"), + RESTART_DISCONNECT("drain.restart-disconnect", level = SystemLevel.WARNING), + NO_LOBBY_AVAILABLE("discovery.no-lobby-available", level = SystemLevel.ERROR), +} diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt index fdd5037..339c182 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt @@ -18,6 +18,8 @@ import gg.grounds.drain.DrainListener import gg.grounds.drain.DrainManager import gg.grounds.drain.DrainTransferCookie import gg.grounds.gameserver.GameServerStateManager +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.ProxyServiceRegistry import gg.grounds.proxy.api.ServerDisplayQuery import kotlinx.coroutines.CoroutineScope @@ -45,6 +47,12 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { @Subscribe fun onProxyInitialize(event: ProxyInitializeEvent) { + val messages = + Translations.forBundleWithSystemRenderer( + "gg.grounds.agones.messages", + javaClass.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ) val discoveryConfig = DiscoveryConfig.fromEnv() logger.info( "Loaded Agones discovery config (namespace={}, labelSelector={}, lobbyLabel={}, lobbyValue={}, runningStates={}, pollInterval={}s, addressType={}, port={})", @@ -70,6 +78,7 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { discoveryConfig, drainTransferCookie = drainCookie, sourceCookiePending = { playerId -> drainManager.isCookiePending(playerId) }, + messages = messages, ) .also { it.start() } @@ -82,7 +91,11 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { proxyServer.commandManager.register( proxyServer.commandManager.metaBuilder("agones").build(), - AgonesCommand(proxyServer, { serverName -> discoveryService.getServerRole(serverName) }), + AgonesCommand( + proxyServer, + { serverName -> discoveryService.getServerRole(serverName) }, + messages = messages, + ), ) val drainConfig = DrainConfig.fromEnv() @@ -95,6 +108,7 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { { serverName -> discoveryService.getServerRole(serverName) }, discoveryConfig.lobbyValue, drainCookie, + messages = messages, ) proxyServer.eventManager.register(this, DrainListener(drainManager)) proxyServer.eventManager.register( @@ -113,6 +127,7 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) { logger, discoveryService::lobbyCandidates, { drainManager.isDraining }, + messages = messages, ) .also { it.start() } drainHttpServer = diff --git a/velocity/src/main/kotlin/gg/grounds/command/AgonesCommand.kt b/velocity/src/main/kotlin/gg/grounds/command/AgonesCommand.kt index b626390..e16d4a9 100644 --- a/velocity/src/main/kotlin/gg/grounds/command/AgonesCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/command/AgonesCommand.kt @@ -3,10 +3,13 @@ package gg.grounds.command import com.velocitypowered.api.command.SimpleCommand import com.velocitypowered.api.proxy.ConsoleCommandSource import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.AgonesMessage +import gg.grounds.i18n.Palette +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.ProxyService import gg.grounds.proxy.api.ProxyServiceRegistry import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor class AgonesCommand( private val proxyServer: ProxyServer, @@ -19,6 +22,12 @@ class AgonesCommand( private val proxyService: () -> ProxyService? = { ProxyServiceRegistry.get(ProxyService::class.java) }, + private val messages: Translations = + Translations.forBundleWithSystemRenderer( + "gg.grounds.agones.messages", + AgonesCommand::class.java.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ), ) : SimpleCommand { override fun execute(invocation: SimpleCommand.Invocation) { @@ -26,7 +35,7 @@ class AgonesCommand( val servers = proxyServer.allServers if (servers.isEmpty()) { - source.sendMessage(Component.text("No servers registered.", NamedTextColor.RED)) + messages.send(source, AgonesMessage.NO_SERVERS) return } @@ -36,7 +45,7 @@ class AgonesCommand( // printing this proxy's numbers as if they were everyone's. val counts = proxyService()?.getNetworkPlayerCounts() - source.sendMessage(Component.text("--- Agones Servers ---", NamedTextColor.GOLD)) + messages.send(source, AgonesMessage.SERVER_HEADER) for (server in servers) { val name = server.serverInfo.name @@ -45,35 +54,31 @@ class AgonesCommand( val type = getServerRole(name) ?: "unknown" val typeColor = when (type) { - "lobby" -> NamedTextColor.GREEN - "game" -> NamedTextColor.AQUA - "match" -> NamedTextColor.LIGHT_PURPLE - else -> NamedTextColor.GRAY + "lobby" -> Palette.SUCCESS + "game" -> Palette.ACCENT + "match" -> Palette.INFO + else -> Palette.TEXT_MUTED } - - val line = - Component.text() - .append(Component.text(" $name", NamedTextColor.WHITE)) - .append(Component.text(" [$type]", typeColor)) - .append(Component.text(" $address", NamedTextColor.GRAY)) - .append(Component.text(" ($playerCount players)", NamedTextColor.YELLOW)) - .build() - - source.sendMessage(line) + messages.send( + source, + AgonesMessage.SERVER_LINE, + "name" to Component.text(name, Palette.TEXT), + "type" to Component.text(type, typeColor), + "address" to Component.text(address.toString(), Palette.TEXT_MUTED), + "players" to playerCount, + ) } val total = counts?.total ?: proxyServer.playerCount - source.sendMessage( - Component.text("Total: ${servers.size} servers, $total players", NamedTextColor.GOLD) + messages.send( + source, + AgonesMessage.SERVER_TOTAL, + "servers" to servers.size, + "players" to total, ) if (counts == null) { - source.sendMessage( - Component.text( - "Player counts are for this proxy only — the presence service is unreachable.", - NamedTextColor.RED, - ) - ) + messages.send(source, AgonesMessage.LOCAL_PLAYER_COUNTS) } } diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt index c2438d9..6c41037 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt @@ -11,11 +11,13 @@ import com.velocitypowered.api.network.ProtocolVersion import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.proxy.server.RegisteredServer import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.AgonesMessage import gg.grounds.drain.DrainTransferCookie +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit -import net.kyori.adventure.text.Component internal fun selectDrainStaticServer( serverName: String, @@ -55,6 +57,12 @@ class DiscoveryPlayerListener( private val serverRole: (String) -> String?, private val drainTransferCookie: DrainTransferCookie = DrainTransferCookie(), private val sourceCookiePending: (String) -> Boolean = { false }, + private val messages: Translations = + Translations.forBundleWithSystemRenderer( + "gg.grounds.agones.messages", + DiscoveryPlayerListener::class.java.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ), ) { private val pendingCookies = ConcurrentHashMap() @@ -74,9 +82,7 @@ class DiscoveryPlayerListener( event.result = ResultedEvent.ComponentResult.denied( - Component.text( - "No lobby servers are currently available. Please try again in a moment." - ) + messages.render(AgonesMessage.NO_LOBBY_AVAILABLE, event.player) ) } diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt index a427810..f835c9e 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt @@ -5,6 +5,8 @@ import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.proxy.server.RegisteredServer import com.velocitypowered.api.proxy.server.ServerInfo import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.ProxyService import gg.grounds.proxy.api.ProxyServiceRegistry import io.kubernetes.client.openapi.ApiException @@ -57,6 +59,12 @@ class DiscoveryService( private val drainTransferCookie: gg.grounds.drain.DrainTransferCookie = gg.grounds.drain.DrainTransferCookie(), private val sourceCookiePending: (String) -> Boolean = { false }, + private val messages: Translations = + Translations.forBundleWithSystemRenderer( + "gg.grounds.agones.messages", + DiscoveryService::class.java.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ), ) { private val gson = Gson() private lateinit var customObjectsApi: CustomObjectsApi @@ -173,6 +181,7 @@ class DiscoveryService( this::getServerRole, drainTransferCookie, sourceCookiePending, + messages, ), ) } diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/OutdatedLobbyMover.kt b/velocity/src/main/kotlin/gg/grounds/discovery/OutdatedLobbyMover.kt index 3f43adc..f86533d 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/OutdatedLobbyMover.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/OutdatedLobbyMover.kt @@ -3,10 +3,12 @@ package gg.grounds.discovery import com.velocitypowered.api.proxy.Player import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.AgonesMessage +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit -import net.kyori.adventure.text.Component import org.slf4j.Logger /** @@ -54,6 +56,12 @@ class OutdatedLobbyMover( /** A draining proxy sends its lobby players to another proxy instead; moving them is moot. */ private val isDraining: () -> Boolean, private val maxMovesPerTick: Int = DEFAULT_MAX_MOVES_PER_TICK, + private val messages: Translations = + Translations.forBundleWithSystemRenderer( + "gg.grounds.agones.messages", + OutdatedLobbyMover::class.java.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ), ) { private val inFlight: MutableSet = ConcurrentHashMap.newKeySet() private var task: ScheduledTask? = null @@ -100,7 +108,7 @@ class OutdatedLobbyMover( from, targetName, ) - player.sendMessage(MOVE_MESSAGE) + messages.send(player, AgonesMessage.MOVING_TO_UPDATED_LOBBY) // connect(), not fireAndForget(): the future says when the move is over, and a failed move // leaves the player where they are — on a lobby that still works — to be retried next tick. player.createConnectionRequest(target).connect().whenComplete { result, error -> @@ -119,6 +127,5 @@ class OutdatedLobbyMover( companion object { const val DEFAULT_MAX_MOVES_PER_TICK = 10 private const val TICK_SECONDS = 5L - val MOVE_MESSAGE: Component = Component.text("Moving you to an updated lobby…") } } diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt index ab4c609..22e9788 100644 --- a/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainListener.kt @@ -16,7 +16,8 @@ class DrainListener(private val drainManager: DrainManager) { @Subscribe fun onLogin(event: LoginEvent) { if (!drainManager.isDraining) return - event.result = ResultedEvent.ComponentResult.denied(DrainManager.RESTART_MESSAGE) + event.result = + ResultedEvent.ComponentResult.denied(drainManager.restartMessage(event.player)) } /** diff --git a/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt b/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt index dbb4153..caf49a9 100644 --- a/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt +++ b/velocity/src/main/kotlin/gg/grounds/drain/DrainManager.kt @@ -4,9 +4,11 @@ import com.velocitypowered.api.network.ProtocolVersion import com.velocitypowered.api.proxy.Player import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.AgonesMessage +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages import java.net.InetSocketAddress import java.util.concurrent.TimeUnit -import net.kyori.adventure.text.Component import org.slf4j.Logger /** @@ -48,6 +50,12 @@ class DrainManager( .repeat(1, TimeUnit.SECONDS) .schedule() }, + private val messages: Translations = + Translations.forBundleWithSystemRenderer( + "gg.grounds.agones.messages", + DrainManager::class.java.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ), ) { @Volatile var isDraining: Boolean = false @@ -162,7 +170,7 @@ class DrainManager( return true } if (force) { - player.disconnect(RESTART_MESSAGE) + player.disconnect(restartMessage(player)) return true } return false @@ -186,10 +194,9 @@ class DrainManager( fun isCookiePending(playerId: String): Boolean = transferStager.isPending(playerId) - companion object { - val RESTART_MESSAGE: Component = - Component.text("This proxy is restarting — please reconnect.") + fun restartMessage(player: Player) = messages.render(AgonesMessage.RESTART_DISCONNECT, player) + companion object { /** * A transfer is deferred only for players on a real round server. No server, an unknown * role, a lobby, or a static server means nothing to protect. 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/agones/messages.properties b/velocity/src/main/resources/gg/grounds/agones/messages.properties new file mode 100644 index 0000000..8f388c3 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/agones/messages.properties @@ -0,0 +1,8 @@ +command.no-servers=No servers registered. +command.server-header=--- Agones Servers --- +command.server-line= []
( players) +command.server-total=Total: servers, players +command.local-player-counts=Player counts are for this proxy only — the presence service is unreachable. +discovery.moving-to-updated-lobby=Moving you to an updated lobby… +drain.restart-disconnect=This proxy is restarting — please reconnect. +discovery.no-lobby-available=No lobby servers are currently available. Please try again in a moment. diff --git a/velocity/src/main/resources/gg/grounds/agones/messages_de.properties b/velocity/src/main/resources/gg/grounds/agones/messages_de.properties new file mode 100644 index 0000000..f48a9d3 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/agones/messages_de.properties @@ -0,0 +1,8 @@ +command.no-servers=Keine Server registriert. +command.server-header=--- Agones-Server --- +command.server-line= []
( Spieler) +command.server-total=Gesamt: Server, Spieler +command.local-player-counts=Die Spielerzahlen gelten nur für diesen Proxy — der Anwesenheitsdienst ist nicht erreichbar. +discovery.moving-to-updated-lobby=Du wirst in eine aktualisierte Lobby verschoben… +drain.restart-disconnect=Dieser Proxy wird neu gestartet — bitte verbinde dich erneut. +discovery.no-lobby-available=Derzeit sind keine Lobby-Server verfügbar. Bitte versuche es gleich noch einmal. diff --git a/velocity/src/test/kotlin/gg/grounds/AgonesMessagesTest.kt b/velocity/src/test/kotlin/gg/grounds/AgonesMessagesTest.kt new file mode 100644 index 0000000..17a4813 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/AgonesMessagesTest.kt @@ -0,0 +1,73 @@ +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 +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AgonesMessagesTest { + private val messages = + Translations.forBundle("gg.grounds.agones.messages", javaClass.classLoader) + private val plain = PlainTextComponentSerializer.plainText() + + @Test + fun `server status arguments remain literal`() { + val rendered = + messages.render( + AgonesMessage.SERVER_LINE, + Locale.ENGLISH, + "name" to "lobby-1", + "type" to "lobby", + "address" to "127.0.0.1:25565", + "players" to 3, + ) + + assertEquals( + "lobby-1 [lobby] 127.0.0.1:25565 (3 players)", + plain.serialize(rendered), + ) + assertEquals(SystemCategory.GENERAL, AgonesMessage.SERVER_LINE.category) + assertEquals(SystemLevel.INFO, AgonesMessage.SERVER_LINE.level) + } + + @Test + fun `restart disconnect is localized without a chat prefix`() { + val rendered = messages.render(AgonesMessage.RESTART_DISCONNECT, Locale.GERMAN) + + assertEquals( + "Dieser Proxy wird neu gestartet — bitte verbinde dich erneut.", + plain.serialize(rendered), + ) + assertEquals(SystemCategory.GENERAL, AgonesMessage.RESTART_DISCONNECT.category) + assertEquals(SystemLevel.WARNING, AgonesMessage.RESTART_DISCONNECT.level) + } + + @Test + fun `missing lobby denial is localized without a chat prefix`() { + val rendered = messages.render(AgonesMessage.NO_LOBBY_AVAILABLE, Locale.GERMAN) + + assertEquals( + "Derzeit sind keine Lobby-Server verfügbar. Bitte versuche es gleich noch einmal.", + plain.serialize(rendered), + ) + assertEquals(SystemCategory.GENERAL, AgonesMessage.NO_LOBBY_AVAILABLE.category) + assertEquals(SystemLevel.ERROR, AgonesMessage.NO_LOBBY_AVAILABLE.level) + } + + @Test + fun `English and German bundles cover every Agones message`() { + assertEquals( + emptyList(), + BundleCheck.problems( + "gg.grounds.agones.messages", + javaClass.classLoader, + AgonesMessage.entries, + translations = listOf(Locale.GERMAN), + ), + ) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/command/AgonesCommandCountsTest.kt b/velocity/src/test/kotlin/gg/grounds/command/AgonesCommandCountsTest.kt index bfc3a3f..6869ca9 100644 --- a/velocity/src/test/kotlin/gg/grounds/command/AgonesCommandCountsTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/command/AgonesCommandCountsTest.kt @@ -9,6 +9,7 @@ import gg.grounds.proxy.api.NetworkPlayerCounts import gg.grounds.proxy.api.ProxyService import java.lang.reflect.Proxy import java.net.InetSocketAddress +import java.util.Optional import net.kyori.adventure.text.Component import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer import org.junit.jupiter.api.Assertions.assertFalse @@ -125,6 +126,7 @@ class AgonesCommandCountsTest { response != null -> response method.returnType == Boolean::class.javaPrimitiveType -> false method.returnType == Int::class.javaPrimitiveType -> 0 + method.returnType == Optional::class.java -> Optional.empty() method.returnType == Void.TYPE -> null else -> null }