From 64942808905a03b0f779c371af6313d57cc5f2fc Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Wed, 14 Oct 2026 23:29:09 +0200 Subject: [PATCH 1/3] feat(chat): add recipient rendering and appearance APIs --- api/build.gradle.kts | 8 +- .../proxy/api/PlayerChatAppearanceQuery.kt | 18 ++ .../proxy/api/PlayerMessageRenderer.kt | 24 +++ .../grounds/proxy/api/ProxyServiceRegistry.kt | 16 ++ .../proxy/api/PlayerMessageRenderersTest.kt | 79 ++++++++ .../presentation/VelocitySystemMessages.kt | 21 ++ .../proxy/velocity/GroundsProxyPlugin.kt | 41 ++-- .../proxy/velocity/ProxyServiceImpl.kt | 3 +- .../proxy/velocity/ProxySystemMessage.kt | 67 +++++++ .../proxy/velocity/command/MotdCommand.kt | 182 ++++++++++-------- .../proxy/velocity/command/OnlineCommand.kt | 38 ++-- .../proxy/velocity/command/RegionCommand.kt | 47 ++--- .../gg/grounds/proxy/messages.properties | 56 ++++++ .../gg/grounds/proxy/messages_de.properties | 56 ++++++ .../gg/grounds/proxy/messages_es.properties | 56 ++++++ .../gg/grounds/proxy/messages_fr.properties | 56 ++++++ .../proxy/velocity/tab/ProxyBundleTest.kt | 41 +++- 17 files changed, 666 insertions(+), 143 deletions(-) create mode 100644 api/src/main/kotlin/gg/grounds/proxy/api/PlayerChatAppearanceQuery.kt create mode 100644 api/src/main/kotlin/gg/grounds/proxy/api/PlayerMessageRenderer.kt create mode 100644 api/src/test/kotlin/gg/grounds/proxy/api/PlayerMessageRenderersTest.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/presentation/VelocitySystemMessages.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 6146621..fe8d2d2 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -6,4 +6,10 @@ tasks.withType { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } } -dependencies { compileOnly("net.kyori:adventure-api:5.2.0") } +dependencies { + compileOnly("net.kyori:adventure-api:5.2.0") + testImplementation("net.kyori:adventure-api:5.2.0") + testImplementation("org.junit.jupiter:junit-jupiter-api:6.1.3") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.1.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.1.3") +} diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerChatAppearanceQuery.kt b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerChatAppearanceQuery.kt new file mode 100644 index 0000000..65c3c58 --- /dev/null +++ b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerChatAppearanceQuery.kt @@ -0,0 +1,18 @@ +package gg.grounds.proxy.api + +import java.util.UUID + +/** + * Per-recipient chat rendering capabilities for an online player. + * + * Providers must fail closed: an offline player, unknown client platform, or unverified resource + * pack returns the default text-only appearance. + */ +interface PlayerChatAppearanceQuery { + fun appearanceOf(playerId: UUID): PlayerChatAppearance +} + +data class PlayerChatAppearance( + val javaHeadsSupported: Boolean = false, + val chatFontReady: Boolean = false, +) diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerMessageRenderer.kt b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerMessageRenderer.kt new file mode 100644 index 0000000..479a449 --- /dev/null +++ b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerMessageRenderer.kt @@ -0,0 +1,24 @@ +package gg.grounds.proxy.api + +import java.util.UUID +import net.kyori.adventure.text.Component + +/** Optional per-player renderer for semantic Components delivered by [ProxyService]. */ +fun interface PlayerMessageRenderer { + /** Returns a rendered component, or null when this provider does not own [message]. */ + fun render(playerId: UUID, message: Component): Component? +} + +/** Resolves the current renderer at delivery time and safely preserves unhandled messages. */ +object PlayerMessageRenderers { + @JvmStatic + fun renderOrOriginal(playerId: UUID, message: Component): Component = + try { + ProxyServiceRegistry.get(PlayerMessageRenderer::class.java)?.render(playerId, message) + ?: message + } catch (_: RuntimeException) { + message + } catch (_: LinkageError) { + message + } +} diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt b/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt index f8e25f2..663bc29 100644 --- a/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt +++ b/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt @@ -39,6 +39,10 @@ object ProxyServiceRegistry { services[serviceClass] = instance } + /** Publishes only when no provider already owns [serviceClass]. */ + fun registerIfAbsent(serviceClass: Class, instance: T): Boolean = + services.putIfAbsent(serviceClass, instance) == null + /** The registered implementation, or null when no plugin provides it. */ fun get(serviceClass: Class): T? { return serviceClass.cast(services[serviceClass]) @@ -48,4 +52,16 @@ object ProxyServiceRegistry { fun unregister(serviceClass: Class) { services.remove(serviceClass) } + + /** Withdraws [instance] only while it still owns [serviceClass]. */ + fun unregister(serviceClass: Class, instance: T): Boolean { + var removed = false + services.computeIfPresent(serviceClass) { _, current -> + if (current === instance) { + removed = true + null + } else current + } + return removed + } } diff --git a/api/src/test/kotlin/gg/grounds/proxy/api/PlayerMessageRenderersTest.kt b/api/src/test/kotlin/gg/grounds/proxy/api/PlayerMessageRenderersTest.kt new file mode 100644 index 0000000..b96f8e6 --- /dev/null +++ b/api/src/test/kotlin/gg/grounds/proxy/api/PlayerMessageRenderersTest.kt @@ -0,0 +1,79 @@ +package gg.grounds.proxy.api + +import java.util.UUID +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PlayerMessageRenderersTest { + @AfterEach + fun clearRenderer() { + ProxyServiceRegistry.unregister(PlayerMessageRenderer::class.java) + } + + @Test + fun `missing renderer preserves the exact rich component`() { + val original = richMessage() + + assertSame(original, PlayerMessageRenderers.renderOrOriginal(PLAYER_ID, original)) + } + + @Test + fun `runtime and linkage failures preserve the exact rich component`() { + val original = richMessage() + listOf( + PlayerMessageRenderer { _, _ -> throw IllegalStateException("unavailable") }, + PlayerMessageRenderer { _, _ -> throw NoClassDefFoundError("unavailable") }, + ) + .forEach { renderer -> + ProxyServiceRegistry.register(PlayerMessageRenderer::class.java, renderer) + assertSame(original, PlayerMessageRenderers.renderOrOriginal(PLAYER_ID, original)) + } + } + + @Test + fun `equal but distinct provider cannot withdraw the owner`() { + class EqualRenderer : PlayerMessageRenderer { + override fun render(playerId: UUID, message: Component) = message + + override fun equals(other: Any?) = other is EqualRenderer + + override fun hashCode() = 1 + } + val owner = EqualRenderer() + ProxyServiceRegistry.register(PlayerMessageRenderer::class.java, owner) + assertFalse( + ProxyServiceRegistry.unregister(PlayerMessageRenderer::class.java, EqualRenderer()) + ) + assertSame(owner, ProxyServiceRegistry.get(PlayerMessageRenderer::class.java)) + } + + @Test + fun `provider registration and removal preserve ownership`() { + val first = PlayerMessageRenderer { _, message -> message } + val second = PlayerMessageRenderer { _, message -> message } + + assertTrue(ProxyServiceRegistry.registerIfAbsent(PlayerMessageRenderer::class.java, first)) + assertFalse( + ProxyServiceRegistry.registerIfAbsent(PlayerMessageRenderer::class.java, second) + ) + assertSame(first, ProxyServiceRegistry.get(PlayerMessageRenderer::class.java)) + assertFalse(ProxyServiceRegistry.unregister(PlayerMessageRenderer::class.java, second)) + assertSame(first, ProxyServiceRegistry.get(PlayerMessageRenderer::class.java)) + assertTrue(ProxyServiceRegistry.unregister(PlayerMessageRenderer::class.java, first)) + } + + private fun richMessage(): Component = + Component.text("arbitrary") + .clickEvent(ClickEvent.runCommand("/keep")) + .hoverEvent(HoverEvent.showText(Component.text("still here"))) + + companion object { + private val PLAYER_ID = UUID.fromString("67e55044-10b1-426f-9247-bb680e5fe0c8") + } +} 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/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt index 5013c2f..fa8511b 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -13,6 +13,8 @@ import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier import gg.grounds.BuildInfo import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages +import gg.grounds.proxy.api.PlayerMessageRenderers import gg.grounds.proxy.api.PlayerRoleQuery import gg.grounds.proxy.api.ProxyService import gg.grounds.proxy.api.ProxyServiceRegistry @@ -35,8 +37,6 @@ import io.nats.client.Subscription import java.net.InetSocketAddress import java.time.Duration import java.util.concurrent.TimeUnit -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer import org.slf4j.Logger @@ -112,6 +112,12 @@ internal fun resolveConfigServiceTarget( class GroundsProxyPlugin @Inject constructor(private val proxy: ProxyServer, private val logger: Logger) { + private val messages = + Translations.forBundleWithSystemRenderer( + "gg.grounds.proxy.messages", + javaClass.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ) private lateinit var natsHandler: NatsHandler private val crossProxySubscriptions = mutableListOf() @@ -171,14 +177,20 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { System.getenv("GROUNDS_LOBBY_SERVER_PREFIX")?.trim()?.takeIf(String::isNotEmpty) ?: "lobby" val region = { RegionCatalog.currentCode() } - proxy.commandManager.register("online", OnlineCommand(service)) + proxy.commandManager.register("online", OnlineCommand(service, messages)) proxy.commandManager.register( "region", - RegionCommand(catalog, region, service) { player -> - player.currentServer - .map { it.serverInfo.name.startsWith(lobbyServerPrefix, ignoreCase = true) } - .orElse(false) - }, + RegionCommand( + catalog, + region, + service, + messages, + isOnLobby = { player -> + player.currentServer + .map { it.serverInfo.name.startsWith(lobbyServerPrefix, ignoreCase = true) } + .orElse(false) + }, + ), ) // How many players each server holds, answered to a backend that asks. Registered here @@ -223,7 +235,6 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { } roster.refresh() - val messages = Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader) // Looked up per call, not captured: plugin-permissions may register after this runs, and a // reference taken now would stay null for the life of the proxy. val tab = @@ -302,6 +313,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { manager = manager, motdGg = MotdGgClient("plugin-proxy/${BuildInfo.VERSION} (+https://grounds.gg)"), async = { task -> proxy.scheduler.buildTask(this, task).schedule() }, + messages = messages, counts = { MotdCommand.Counts( players = networkPlayerCount ?: proxy.playerCount, @@ -446,7 +458,10 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { CrossProxyMessage.subjectFor(CrossProxyMessage.SYSTEM, proxyId) ) { raw -> forTarget(raw, CrossProxyMessage.SYSTEM) { player, payload -> - player.sendMessage(GsonComponentSerializer.gson().deserialize(payload)) + val message = GsonComponentSerializer.gson().deserialize(payload) + player.sendMessage( + PlayerMessageRenderers.renderOrOriginal(player.uniqueId, message) + ) } } @@ -464,8 +479,10 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { ) return@forTarget } - player.sendMessage( - Component.text("Folge Party-Leader zu $serverName...", NamedTextColor.GREEN) + messages.send( + player, + ProxySystemMessage.PARTY_TRANSFER_FOLLOWING, + "server" to serverName, ) player.createConnectionRequest(server).fireAndForget() } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt index 9c9a7d6..39917fe 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt @@ -3,6 +3,7 @@ package gg.grounds.proxy.velocity import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.proxy.api.NetworkPlayerCounts import gg.grounds.proxy.api.NetworkProxyCounts +import gg.grounds.proxy.api.PlayerMessageRenderers import gg.grounds.proxy.api.PlayerPresence import gg.grounds.proxy.api.PlayerSessionQuery import gg.grounds.proxy.api.ProxyService @@ -84,7 +85,7 @@ class ProxyServiceImpl( override fun sendToPlayer(targetId: UUID, message: Component) { val local = proxy.getPlayer(targetId).orElse(null) if (local != null) { - local.sendMessage(message) + local.sendMessage(PlayerMessageRenderers.renderOrOriginal(targetId, message)) } else { val json = GsonComponentSerializer.gson().serialize(message) publishToHolder(CrossProxyMessage.SYSTEM, targetId, json) diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt new file mode 100644 index 0000000..757a7bc --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt @@ -0,0 +1,67 @@ +package gg.grounds.proxy.velocity + +import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemMessageKey + +/** Player-facing system chat owned by the proxy. */ +enum class ProxySystemMessage( + override val id: String, + override val category: SystemCategory = SystemCategory.INFO, +) : SystemMessageKey { + ONLINE_UNAVAILABLE("online.unavailable", SystemCategory.ERROR), + ONLINE_HEADER("online.header"), + ONLINE_NOBODY("online.nobody"), + ONLINE_REGION("online.region"), + ONLINE_UNKNOWN_REGION("online.region.unknown"), + ONLINE_PROXY("online.proxy"), + REGION_PLAYER_ONLY("region.player-only", SystemCategory.ERROR), + REGION_NONE("region.none", SystemCategory.ERROR), + REGION_UNKNOWN("region.unknown", SystemCategory.ERROR), + REGION_ALREADY_HERE("region.already-here", SystemCategory.WARNING), + REGION_UNAVAILABLE("region.unavailable", SystemCategory.ERROR), + REGION_MOVING("region.moving", SystemCategory.SUCCESS), + REGION_HEADER("region.header"), + REGION_ENTRY("region.entry"), + REGION_ENTRY_CURRENT("region.entry.current"), + REGION_USAGE("region.usage"), + PARTY_TRANSFER_FOLLOWING("party.transfer.following", SystemCategory.PARTY), + MOTD_UNKNOWN_SUBCOMMAND("motd.unknown-subcommand", SystemCategory.ERROR), + MOTD_NOT_SET("motd.not-set", SystemCategory.WARNING), + MOTD_NOT_READ("motd.not-read", SystemCategory.WARNING), + MOTD_HEADER("motd.header"), + MOTD_SOURCE("motd.source"), + MOTD_CHANGED_BY("motd.changed-by"), + MOTD_CHANGED_AT("motd.changed-at"), + MOTD_MAX_PLAYERS("motd.max-players"), + MOTD_MAX_PLAYERS_DEFAULT("motd.max-players.default"), + MOTD_RAW("motd.raw"), + MOTD_USAGE_SET("motd.usage.set", SystemCategory.WARNING), + MOTD_UPDATED("motd.updated", SystemCategory.SUCCESS), + MOTD_USAGE_IMPORT("motd.usage.import", SystemCategory.WARNING), + MOTD_FETCHING("motd.fetching"), + MOTD_IMPORT_FAILED("motd.import-failed", SystemCategory.ERROR), + MOTD_ICON_SKIPPED("motd.icon-skipped", SystemCategory.WARNING), + MOTD_IMPORTED("motd.imported", SystemCategory.SUCCESS), + MOTD_IMPORTED_NAMED("motd.imported.named", SystemCategory.SUCCESS), + MOTD_REQUIRED_FOR_MAX_PLAYERS("motd.max-players.required", SystemCategory.ERROR), + MOTD_USAGE_MAX_PLAYERS("motd.usage.max-players", SystemCategory.WARNING), + MOTD_INVALID_PLAYER_COUNT("motd.invalid-player-count", SystemCategory.ERROR), + MOTD_MAX_PLAYERS_CLEARED("motd.max-players.cleared", SystemCategory.SUCCESS), + MOTD_MAX_PLAYERS_SET("motd.max-players.set", SystemCategory.SUCCESS), + MOTD_REMOVED("motd.removed", SystemCategory.SUCCESS), + MOTD_NOTHING_TO_REMOVE("motd.nothing-to-remove"), + MOTD_REMOVE_FAILED("motd.remove-failed", SystemCategory.ERROR), + MOTD_NOTHING_TO_PREVIEW("motd.nothing-to-preview", SystemCategory.ERROR), + MOTD_HELP_HEADER("motd.help.header"), + MOTD_HELP_SHOW("motd.help.show"), + MOTD_HELP_SET("motd.help.set"), + MOTD_HELP_IMPORT("motd.help.import"), + MOTD_HELP_MAX_PLAYERS("motd.help.max-players"), + MOTD_HELP_PREVIEW("motd.help.preview"), + MOTD_HELP_RESET("motd.help.reset"), + MOTD_PLACEHOLDERS("motd.placeholders"), + MOTD_SAVE_FAILED("motd.save-failed", SystemCategory.ERROR), + MOTD_INVALID_MINI_MESSAGE("motd.invalid-mini-message", SystemCategory.ERROR), + MOTD_EXTRA_LINES("motd.extra-lines", SystemCategory.WARNING), + MOTD_PREVIEW("motd.preview"), +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt index 5b6b7f8..30363d4 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/MotdCommand.kt @@ -3,6 +3,8 @@ package gg.grounds.proxy.velocity.command import com.velocitypowered.api.command.CommandSource import com.velocitypowered.api.command.SimpleCommand import com.velocitypowered.api.proxy.Player +import gg.grounds.i18n.Translations +import gg.grounds.proxy.velocity.ProxySystemMessage import gg.grounds.proxy.velocity.motd.MotdDocument import gg.grounds.proxy.velocity.motd.MotdFormatException import gg.grounds.proxy.velocity.motd.MotdGgClient @@ -28,6 +30,7 @@ class MotdCommand( private val manager: MotdManager, private val motdGg: MotdGgClient, private val async: (Runnable) -> Unit, + private val messages: Translations, private val counts: () -> Counts, ) : SimpleCommand { @@ -53,7 +56,11 @@ class MotdCommand( "preview" -> preview(source, rest) "help" -> help(source) else -> { - source.sendMessage(error("Unknown subcommand '$subcommand'.")) + messages.send( + source, + ProxySystemMessage.MOTD_UNKNOWN_SUBCOMMAND, + "subcommand" to subcommand, + ) help(source) } } @@ -62,28 +69,40 @@ class MotdCommand( private fun show(source: CommandSource) { val document = manager.current() if (document == null) { - source.sendMessage( - Component.text( - if (manager.loaded()) "No MOTD is set; the proxy default is being served." - else "The MOTD has not been read yet.", - NamedTextColor.YELLOW, - ) + messages.send( + source, + if (manager.loaded()) ProxySystemMessage.MOTD_NOT_SET + else ProxySystemMessage.MOTD_NOT_READ, ) return } - source.sendMessage(Component.text("--- MOTD ---", NamedTextColor.GOLD)) - source.sendMessage(label("source", document.source ?: "/motd")) - document.updatedBy?.let { source.sendMessage(label("changed by", it)) } - document.updatedAt?.let { source.sendMessage(label("changed at", it)) } - source.sendMessage(label("max players", document.maxPlayers?.toString() ?: "proxy default")) - source.sendMessage(Component.text("raw:", NamedTextColor.GRAY)) + messages.send(source, ProxySystemMessage.MOTD_HEADER) + messages.send( + source, + ProxySystemMessage.MOTD_SOURCE, + "source" to (document.source ?: "/motd"), + ) + document.updatedBy?.let { + messages.send(source, ProxySystemMessage.MOTD_CHANGED_BY, "actor" to it) + } + document.updatedAt?.let { + messages.send(source, ProxySystemMessage.MOTD_CHANGED_AT, "time" to it) + } + document.maxPlayers?.let { + messages.send(source, ProxySystemMessage.MOTD_MAX_PLAYERS, "count" to it) + } ?: messages.send(source, ProxySystemMessage.MOTD_MAX_PLAYERS_DEFAULT) + messages.send(source, ProxySystemMessage.MOTD_RAW) source.sendMessage(Component.text(document.text, NamedTextColor.WHITE)) sendPreview(source, document.text) } private fun set(source: CommandSource, text: String) { if (text.isEmpty()) { - source.sendMessage(error("Usage: /motd set ")) + messages.send( + source, + ProxySystemMessage.MOTD_USAGE_SET, + "usage" to "/motd set ", + ) return } if (!parses(source, text)) return @@ -91,33 +110,35 @@ class MotdCommand( val document = (manager.current() ?: MotdDocument(text)).copy(text = text, source = "/motd") store(source, document) { - source.sendMessage(Component.text("MOTD updated.", NamedTextColor.GREEN)) + messages.send(source, ProxySystemMessage.MOTD_UPDATED) sendPreview(source, text) } } private fun import(source: CommandSource, idOrUrl: String) { if (idOrUrl.isEmpty()) { - source.sendMessage(error("Usage: /motd import ")) + messages.send( + source, + ProxySystemMessage.MOTD_USAGE_IMPORT, + "usage" to "/motd import ", + ) return } - source.sendMessage(Component.text("Fetching from motd.gg...", NamedTextColor.GRAY)) + messages.send(source, ProxySystemMessage.MOTD_FETCHING) async { val imported = try { motdGg.import(idOrUrl) } catch (ex: MotdFormatException) { - source.sendMessage(error("Import failed: ${ex.message}")) + messages.send( + source, + ProxySystemMessage.MOTD_IMPORT_FAILED, + "error" to (ex.message ?: ex::class.java.simpleName), + ) return@async } if (imported.hasFavicon) { - source.sendMessage( - Component.text( - "The motd.gg document has a server icon; it was not imported " + - "(the network icon is served from the CDN).", - NamedTextColor.YELLOW, - ) - ) + messages.send(source, ProxySystemMessage.MOTD_ICON_SKIPPED) } val document = (manager.current() ?: MotdDocument(imported.text)).copy( @@ -125,13 +146,12 @@ class MotdCommand( source = "motd.gg/${imported.id}", ) writeAndReport(source, document) { - source.sendMessage( - Component.text( - "MOTD imported from motd.gg/${imported.id}" + - (imported.name?.let { " ($it)" } ?: "") + - ".", - NamedTextColor.GREEN, - ) + messages.send( + source, + if (imported.name == null) ProxySystemMessage.MOTD_IMPORTED + else ProxySystemMessage.MOTD_IMPORTED_NAMED, + "id" to imported.id, + "name" to imported.name.orEmpty(), ) sendPreview(source, imported.text) } @@ -141,32 +161,37 @@ class MotdCommand( private fun maxPlayers(source: CommandSource, argument: String) { val document = manager.current() if (document == null) { - source.sendMessage( - error("No MOTD is set — run /motd set first; max players is stored alongside it.") - ) + messages.send(source, ProxySystemMessage.MOTD_REQUIRED_FOR_MAX_PLAYERS) return } val value = when { argument.isEmpty() -> { - source.sendMessage(error("Usage: /motd maxplayers ")) + messages.send( + source, + ProxySystemMessage.MOTD_USAGE_MAX_PLAYERS, + "usage" to "/motd maxplayers ", + ) return } argument.equals("clear", true) -> null else -> argument.toIntOrNull()?.takeIf { it >= 0 } ?: run { - source.sendMessage(error("'$argument' is not a player count.")) + messages.send( + source, + ProxySystemMessage.MOTD_INVALID_PLAYER_COUNT, + "value" to argument, + ) return } } store(source, document.copy(maxPlayers = value)) { - source.sendMessage( - Component.text( - if (value == null) "Max players cleared; the proxy value is used again." - else "Max players set to $value.", - NamedTextColor.GREEN, - ) + messages.send( + source, + if (value == null) ProxySystemMessage.MOTD_MAX_PLAYERS_CLEARED + else ProxySystemMessage.MOTD_MAX_PLAYERS_SET, + "count" to (value ?: 0), ) } } @@ -176,15 +201,17 @@ class MotdCommand( async { try { val removed = manager.clear(actor) - source.sendMessage( - Component.text( - if (removed) "MOTD removed; the proxy default is served again." - else "There was no MOTD to remove.", - NamedTextColor.GREEN, - ) + messages.send( + source, + if (removed) ProxySystemMessage.MOTD_REMOVED + else ProxySystemMessage.MOTD_NOTHING_TO_REMOVE, ) } catch (ex: Exception) { - source.sendMessage(error("Could not remove the MOTD: ${describe(ex)}")) + messages.send( + source, + ProxySystemMessage.MOTD_REMOVE_FAILED, + "error" to describe(ex), + ) } } } @@ -192,7 +219,7 @@ class MotdCommand( private fun preview(source: CommandSource, text: String) { val subject = text.ifEmpty { manager.current()?.text } if (subject == null) { - source.sendMessage(error("Nothing to preview: no MOTD is set.")) + messages.send(source, ProxySystemMessage.MOTD_NOTHING_TO_PREVIEW) return } if (!parses(source, subject)) return @@ -200,26 +227,22 @@ class MotdCommand( } private fun help(source: CommandSource) { - source.sendMessage(Component.text("--- /motd ---", NamedTextColor.GOLD)) + messages.send(source, ProxySystemMessage.MOTD_HELP_HEADER) listOf( - "/motd" to "show the MOTD in use", - "/motd set " to "change it; starts the second line", - "/motd import " to "take the design from motd.gg", - "/motd maxplayers " to "the number right of the slash", - "/motd preview [MiniMessage]" to "render it here without changing anything", - "/motd reset" to "remove it and serve the proxy default", + ProxySystemMessage.MOTD_HELP_SHOW to "/motd", + ProxySystemMessage.MOTD_HELP_SET to "/motd set ", + ProxySystemMessage.MOTD_HELP_IMPORT to "/motd import ", + ProxySystemMessage.MOTD_HELP_MAX_PLAYERS to "/motd maxplayers ", + ProxySystemMessage.MOTD_HELP_PREVIEW to "/motd preview [MiniMessage]", + ProxySystemMessage.MOTD_HELP_RESET to "/motd reset", ) - .forEach { (usage, what) -> - source.sendMessage( - Component.text(" $usage", NamedTextColor.WHITE) - .append(Component.text(" — $what", NamedTextColor.GRAY)) - ) + .forEach { (key, usage) -> + messages.send(source, key, "usage" to usage, "newline" to "") } - source.sendMessage( - Component.text( - "Placeholders: " + MotdPlaceholders.TOKENS.joinToString(", ") { "{{$it}}" }, - NamedTextColor.GRAY, - ) + messages.send( + source, + ProxySystemMessage.MOTD_PLACEHOLDERS, + "placeholders" to MotdPlaceholders.TOKENS.joinToString(", ") { "{{$it}}" }, ) } @@ -237,7 +260,7 @@ class MotdCommand( manager.set(document, actorOf(source)) onSuccess() } catch (ex: Exception) { - source.sendMessage(error("Could not save the MOTD: ${describe(ex)}")) + messages.send(source, ProxySystemMessage.MOTD_SAVE_FAILED, "error" to describe(ex)) } } @@ -246,34 +269,27 @@ class MotdCommand( MiniMessage.miniMessage().deserialize(text) true } catch (ex: ParsingException) { - source.sendMessage(error("That is not valid MiniMessage: ${ex.message}")) + messages.send( + source, + ProxySystemMessage.MOTD_INVALID_MINI_MESSAGE, + "error" to (ex.message ?: ex::class.java.simpleName), + ) false } private fun warnOnExtraLines(source: CommandSource, text: String) { val breaks = LINE_BREAK.findAll(text).count() if (breaks > 1) { - source.sendMessage( - Component.text( - "That is ${breaks + 1} lines; the server list shows two.", - NamedTextColor.YELLOW, - ) - ) + messages.send(source, ProxySystemMessage.MOTD_EXTRA_LINES, "count" to (breaks + 1)) } } private fun sendPreview(source: CommandSource, text: String) { val counts = counts() - source.sendMessage(Component.text("preview:", NamedTextColor.GRAY)) + messages.send(source, ProxySystemMessage.MOTD_PREVIEW) source.sendMessage(manager.preview(text, counts.players, counts.maxPlayers)) } - private fun label(name: String, value: String): Component = - Component.text(" $name: ", NamedTextColor.GRAY) - .append(Component.text(value, NamedTextColor.WHITE)) - - private fun error(message: String): Component = Component.text(message, NamedTextColor.RED) - /** * The store already unwrapped what service-config wrote for a human — a caller that is not * allowed to write says so in the problem's detail. Anything else falls back to the exception, diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/OnlineCommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/OnlineCommand.kt index 850806f..bd20825 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/OnlineCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/OnlineCommand.kt @@ -1,9 +1,9 @@ package gg.grounds.proxy.velocity.command import com.velocitypowered.api.command.SimpleCommand +import gg.grounds.i18n.Translations import gg.grounds.proxy.api.ProxyService -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor +import gg.grounds.proxy.velocity.ProxySystemMessage /** * `/online` — who is online across the whole network, by region and by proxy. @@ -16,28 +16,24 @@ import net.kyori.adventure.text.format.NamedTextColor * number and a network number look identical once rendered, and quietly showing the wrong one is * the failure this exists to avoid. */ -class OnlineCommand(private val proxyService: () -> ProxyService?) : SimpleCommand { +class OnlineCommand( + private val proxyService: () -> ProxyService?, + private val messages: Translations, +) : SimpleCommand { override fun execute(invocation: SimpleCommand.Invocation) { val source = invocation.source() val counts = proxyService()?.getNetworkProxyCounts() if (counts == null) { - source.sendMessage( - Component.text( - "Network player counts are unavailable — the presence service cannot be reached.", - NamedTextColor.RED, - ) - ) + messages.send(source, ProxySystemMessage.ONLINE_UNAVAILABLE) return } - source.sendMessage( - Component.text("--- ${counts.total} players online ---", NamedTextColor.GOLD) - ) + messages.send(source, ProxySystemMessage.ONLINE_HEADER, "count" to counts.total) if (counts.proxies.isEmpty()) { - source.sendMessage(Component.text(" nobody is connected", NamedTextColor.GRAY)) + messages.send(source, ProxySystemMessage.ONLINE_NOBODY) return } @@ -52,11 +48,19 @@ class OnlineCommand(private val proxyService: () -> ProxyService?) : SimpleComma ordered.forEach { region -> val proxies = byRegion.getValue(region).sortedByDescending { it.players } val regionTotal = proxies.sumOf { it.players } - val label = region ?: "unknown region" - source.sendMessage(Component.text("$label — $regionTotal", NamedTextColor.AQUA)) + messages.send( + source, + if (region == null) ProxySystemMessage.ONLINE_UNKNOWN_REGION + else ProxySystemMessage.ONLINE_REGION, + "region" to region.orEmpty(), + "count" to regionTotal, + ) proxies.forEach { proxy -> - source.sendMessage( - Component.text(" ${proxy.proxyId}: ${proxy.players}", NamedTextColor.GRAY) + messages.send( + source, + ProxySystemMessage.ONLINE_PROXY, + "proxy" to proxy.proxyId, + "count" to proxy.players, ) } } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/RegionCommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/RegionCommand.kt index f3f7e2a..1c06f92 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/RegionCommand.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/RegionCommand.kt @@ -2,10 +2,10 @@ package gg.grounds.proxy.velocity.command import com.velocitypowered.api.command.SimpleCommand import com.velocitypowered.api.proxy.Player +import gg.grounds.i18n.Translations import gg.grounds.proxy.api.ProxyService +import gg.grounds.proxy.velocity.ProxySystemMessage import gg.grounds.proxy.velocity.RegionCatalog -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor /** * `/region` — see which regions exist and move to one. @@ -26,6 +26,7 @@ class RegionCommand( private val catalog: RegionCatalog, private val currentRegion: () -> String?, private val proxyService: () -> ProxyService?, + private val messages: Translations, private val isOnLobby: (Player) -> Boolean = { false }, ) : SimpleCommand { @@ -42,16 +43,12 @@ class RegionCommand( val source = invocation.source() val player = source as? Player if (player == null) { - source.sendMessage( - Component.text("Only a player can change region.", NamedTextColor.RED) - ) + messages.send(source, ProxySystemMessage.REGION_PLAYER_ONLY) return } if (catalog.regions.isEmpty()) { - source.sendMessage( - Component.text("No regions are configured on this proxy.", NamedTextColor.RED) - ) + messages.send(source, ProxySystemMessage.REGION_NONE) return } @@ -63,48 +60,44 @@ class RegionCommand( val target = catalog[requested] if (target == null) { - player.sendMessage( - Component.text( - "Unknown region '$requested'. Available: ${catalog.codes.joinToString(", ")}", - NamedTextColor.RED, - ) + messages.send( + player, + ProxySystemMessage.REGION_UNKNOWN, + "region" to requested, + "available" to catalog.codes.joinToString(", "), ) return } val here = currentRegion() if (here != null && target.code.equals(here, true)) { - player.sendMessage( - Component.text("You are already in ${target.code}.", NamedTextColor.YELLOW) - ) + messages.send(player, ProxySystemMessage.REGION_ALREADY_HERE, "region" to target.code) return } val service = proxyService() if (service == null) { - player.sendMessage( - Component.text("Region switching is unavailable right now.", NamedTextColor.RED) - ) + messages.send(player, ProxySystemMessage.REGION_UNAVAILABLE) return } - player.sendMessage(Component.text("Moving you to ${target.code}...", NamedTextColor.GREEN)) + messages.send(player, ProxySystemMessage.REGION_MOVING, "region" to target.code) service.transferToHost(player.uniqueId, target.host, target.port) } private fun sendList(player: Player) { val here = currentRegion() - player.sendMessage(Component.text("--- Regions ---", NamedTextColor.GOLD)) + messages.send(player, ProxySystemMessage.REGION_HEADER) catalog.regions.forEach { region -> val isHere = here != null && region.code.equals(here, true) - player.sendMessage( - Component.text( - if (isHere) " ${region.code} (you are here)" else " ${region.code}" - ) - .color(if (isHere) NamedTextColor.GREEN else NamedTextColor.GRAY) + messages.send( + player, + if (isHere) ProxySystemMessage.REGION_ENTRY_CURRENT + else ProxySystemMessage.REGION_ENTRY, + "region" to region.code, ) } - player.sendMessage(Component.text("Use /region to move.", NamedTextColor.GRAY)) + messages.send(player, ProxySystemMessage.REGION_USAGE, "syntax" to "/region ") } override fun suggest(invocation: SimpleCommand.Invocation): List { diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index eda2c85..5202b1a 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -5,3 +5,59 @@ tab.footer=\n Ping \nground tab.server.lobby=Lobby tab.server.game=Game tab.server.match=Match +online.unavailable=Network player counts are unavailable — the presence service cannot be reached. +online.header=--- players online --- +online.nobody=nobody is connected +online.region= — +online.region.unknown=unknown region — +online.proxy=: +region.player-only=Only a player can change region. +region.none=No regions are configured on this proxy. +region.unknown=Unknown region ''. Available: +region.already-here=You are already in . +region.unavailable=Region switching is unavailable right now. +region.moving=Moving you to ... +region.header=--- Regions --- +region.entry= +region.entry.current= (you are here) +region.usage=Use to move. +party.transfer.following=Following the party leader to ... +motd.unknown-subcommand=Unknown subcommand ''. +motd.not-set=No MOTD is set; the proxy default is being served. +motd.not-read=The MOTD has not been read yet. +motd.header=--- MOTD --- +motd.source=source: +motd.changed-by=changed by: +motd.changed-at=changed at: +motd.max-players=max players: +motd.max-players.default=max players: proxy default +motd.raw=raw: +motd.usage.set=Usage: +motd.updated=MOTD updated. +motd.usage.import=Usage: +motd.fetching=Fetching from motd.gg... +motd.import-failed=Import failed: +motd.icon-skipped=The motd.gg document has a server icon; it was not imported (the network icon is served from the CDN). +motd.imported=MOTD imported from motd.gg/. +motd.imported.named=MOTD imported from motd.gg/ (). +motd.max-players.required=No MOTD is set — run /motd set first; max players is stored alongside it. +motd.usage.max-players=Usage: +motd.invalid-player-count='' is not a player count. +motd.max-players.cleared=Max players cleared; the proxy value is used again. +motd.max-players.set=Max players set to . +motd.removed=MOTD removed; the proxy default is served again. +motd.nothing-to-remove=There was no MOTD to remove. +motd.remove-failed=Could not remove the MOTD: +motd.nothing-to-preview=Nothing to preview: no MOTD is set. +motd.help.header=--- /motd --- +motd.help.show= — show the MOTD in use +motd.help.set= — change it; starts the second line +motd.help.import= — take the design from motd.gg +motd.help.max-players= — the number right of the slash +motd.help.preview= — render it here without changing anything +motd.help.reset= — remove it and serve the proxy default +motd.placeholders=Placeholders: +motd.save-failed=Could not save the MOTD: +motd.invalid-mini-message=That is not valid MiniMessage: +motd.extra-lines=That is lines; the server list shows two. +motd.preview=preview: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 6a316dc..34e7cad 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -3,3 +3,59 @@ tab.footer=\n Ping \nground tab.server.lobby=Lobby tab.server.game=Spiel tab.server.match=Match +online.unavailable=Netzwerkweite Spielerzahlen sind nicht verfügbar — der Anwesenheitsdienst ist nicht erreichbar. +online.header=--- Spieler online --- +online.nobody=niemand ist verbunden +online.region= — +online.region.unknown=unbekannte Region — +online.proxy=: +region.player-only=Nur Spieler können die Region wechseln. +region.none=Auf diesem Proxy sind keine Regionen konfiguriert. +region.unknown=Unbekannte Region ''. Verfügbar: +region.already-here=Du bist bereits in . +region.unavailable=Der Regionswechsel ist derzeit nicht verfügbar. +region.moving=Du wirst nach verschoben... +region.header=--- Regionen --- +region.entry= +region.entry.current= (du bist hier) +region.usage=Nutze , um zu wechseln. +party.transfer.following=Folge dem Party-Leader zu ... +motd.unknown-subcommand=Unbekannter Unterbefehl ''. +motd.not-set=Keine MOTD gesetzt; der Proxy-Standard wird angezeigt. +motd.not-read=Die MOTD wurde noch nicht gelesen. +motd.header=--- MOTD --- +motd.source=Quelle: +motd.changed-by=geändert von: +motd.changed-at=geändert am: +motd.max-players=maximale Spieler: +motd.max-players.default=maximale Spieler: Proxy-Standard +motd.raw=Rohtext: +motd.usage.set=Verwendung: +motd.updated=MOTD aktualisiert. +motd.usage.import=Verwendung: +motd.fetching=Von motd.gg wird geladen... +motd.import-failed=Import fehlgeschlagen: +motd.icon-skipped=Das motd.gg-Dokument enthält ein Server-Icon; es wurde nicht importiert (das Netzwerk-Icon kommt vom CDN). +motd.imported=MOTD von motd.gg/ importiert. +motd.imported.named=MOTD von motd.gg/ () importiert. +motd.max-players.required=Keine MOTD gesetzt — führe zuerst /motd set aus; die maximale Spielerzahl wird damit gespeichert. +motd.usage.max-players=Verwendung: +motd.invalid-player-count='' ist keine Spielerzahl. +motd.max-players.cleared=Maximale Spielerzahl entfernt; der Proxy-Wert wird wieder verwendet. +motd.max-players.set=Maximale Spielerzahl auf gesetzt. +motd.removed=MOTD entfernt; der Proxy-Standard wird wieder angezeigt. +motd.nothing-to-remove=Es gab keine MOTD zum Entfernen. +motd.remove-failed=MOTD konnte nicht entfernt werden: +motd.nothing-to-preview=Keine Vorschau möglich: Es ist keine MOTD gesetzt. +motd.help.header=--- /motd --- +motd.help.show= — verwendete MOTD anzeigen +motd.help.set= — ändern; beginnt die zweite Zeile +motd.help.import= — Design von motd.gg übernehmen +motd.help.max-players= — Zahl rechts vom Schrägstrich +motd.help.preview= — hier anzeigen, ohne etwas zu ändern +motd.help.reset= — entfernen und Proxy-Standard verwenden +motd.placeholders=Platzhalter: +motd.save-failed=MOTD konnte nicht gespeichert werden: +motd.invalid-mini-message=Das ist kein gültiges MiniMessage: +motd.extra-lines=Das sind Zeilen; die Serverliste zeigt zwei. +motd.preview=Vorschau: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index ab5f724..4ea37eb 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -3,3 +3,59 @@ tab.footer=\n Ping \nground tab.server.lobby=Lobby tab.server.game=Juego tab.server.match=Partida +online.unavailable=El recuento de jugadores de la red no está disponible — no se puede acceder al servicio de presencia. +online.header=--- jugadores conectados --- +online.nobody=no hay nadie conectado +online.region= — +online.region.unknown=región desconocida — +online.proxy=: +region.player-only=Solo un jugador puede cambiar de región. +region.none=No hay regiones configuradas en este proxy. +region.unknown=Región desconocida ''. Disponibles: +region.already-here=Ya estás en . +region.unavailable=El cambio de región no está disponible ahora mismo. +region.moving=Trasladándote a ... +region.header=--- Regiones --- +region.entry= +region.entry.current= (estás aquí) +region.usage=Usa para cambiar. +party.transfer.following=Siguiendo al líder del grupo hasta ... +motd.unknown-subcommand=Subcomando desconocido ''. +motd.not-set=No hay MOTD; se está usando el valor predeterminado del proxy. +motd.not-read=El MOTD aún no se ha leído. +motd.header=--- MOTD --- +motd.source=origen: +motd.changed-by=modificado por: +motd.changed-at=modificado el: +motd.max-players=máximo de jugadores: +motd.max-players.default=máximo de jugadores: valor del proxy +motd.raw=texto sin procesar: +motd.usage.set=Uso: +motd.updated=MOTD actualizado. +motd.usage.import=Uso: +motd.fetching=Obteniendo desde motd.gg... +motd.import-failed=Error de importación: +motd.icon-skipped=El documento de motd.gg tiene un icono de servidor; no se importó (el icono de la red se sirve desde el CDN). +motd.imported=MOTD importado desde motd.gg/. +motd.imported.named=MOTD importado desde motd.gg/ (). +motd.max-players.required=No hay MOTD — ejecuta primero /motd set; el máximo de jugadores se guarda junto a él. +motd.usage.max-players=Uso: +motd.invalid-player-count='' no es un número de jugadores. +motd.max-players.cleared=Máximo eliminado; se vuelve a usar el valor del proxy. +motd.max-players.set=Máximo de jugadores establecido en . +motd.removed=MOTD eliminado; se vuelve a usar el valor predeterminado del proxy. +motd.nothing-to-remove=No había ningún MOTD que eliminar. +motd.remove-failed=No se pudo eliminar el MOTD: +motd.nothing-to-preview=No hay nada que previsualizar: no hay MOTD. +motd.help.header=--- /motd --- +motd.help.show= — mostrar el MOTD en uso +motd.help.set= — cambiarlo; inicia la segunda línea +motd.help.import= — tomar el diseño de motd.gg +motd.help.max-players= — el número a la derecha de la barra +motd.help.preview= — mostrarlo aquí sin cambiar nada +motd.help.reset= — eliminarlo y usar el valor del proxy +motd.placeholders=Marcadores: +motd.save-failed=No se pudo guardar el MOTD: +motd.invalid-mini-message=No es un MiniMessage válido: +motd.extra-lines=Hay líneas; la lista de servidores muestra dos. +motd.preview=vista previa: diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index 10f5aa8..cb64822 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -3,3 +3,59 @@ tab.footer=\n Ping \nground tab.server.lobby=Lobby tab.server.game=Jeu tab.server.match=Match +online.unavailable=Le nombre de joueurs du réseau est indisponible — le service de présence est inaccessible. +online.header=--- joueurs en ligne --- +online.nobody=personne n'est connecté +online.region= — +online.region.unknown=région inconnue — +online.proxy=: +region.player-only=Seul un joueur peut changer de région. +region.none=Aucune région n'est configurée sur ce proxy. +region.unknown=Région inconnue ''. Disponibles : +region.already-here=Vous êtes déjà dans . +region.unavailable=Le changement de région est indisponible pour le moment. +region.moving=Transfert vers ... +region.header=--- Régions --- +region.entry= +region.entry.current= (vous êtes ici) +region.usage=Utilisez pour changer. +party.transfer.following=Vous suivez le chef du groupe vers ... +motd.unknown-subcommand=Sous-commande inconnue ''. +motd.not-set=Aucun MOTD n'est défini ; la valeur par défaut du proxy est utilisée. +motd.not-read=Le MOTD n'a pas encore été lu. +motd.header=--- MOTD --- +motd.source=source : +motd.changed-by=modifié par : +motd.changed-at=modifié le : +motd.max-players=joueurs maximum : +motd.max-players.default=joueurs maximum : valeur du proxy +motd.raw=texte brut : +motd.usage.set=Utilisation : +motd.updated=MOTD mis à jour. +motd.usage.import=Utilisation : +motd.fetching=Récupération depuis motd.gg... +motd.import-failed=Échec de l'importation : +motd.icon-skipped=Le document motd.gg contient une icône de serveur ; elle n'a pas été importée (l'icône du réseau vient du CDN). +motd.imported=MOTD importé depuis motd.gg/. +motd.imported.named=MOTD importé depuis motd.gg/ (). +motd.max-players.required=Aucun MOTD n'est défini — exécutez d'abord /motd set ; le maximum est stocké avec celui-ci. +motd.usage.max-players=Utilisation : +motd.invalid-player-count='' n'est pas un nombre de joueurs. +motd.max-players.cleared=Maximum supprimé ; la valeur du proxy est de nouveau utilisée. +motd.max-players.set=Maximum défini à . +motd.removed=MOTD supprimé ; la valeur par défaut du proxy est de nouveau utilisée. +motd.nothing-to-remove=Il n'y avait aucun MOTD à supprimer. +motd.remove-failed=Impossible de supprimer le MOTD : +motd.nothing-to-preview=Aucun aperçu : aucun MOTD n'est défini. +motd.help.header=--- /motd --- +motd.help.show= — afficher le MOTD utilisé +motd.help.set= — le modifier ; commence la deuxième ligne +motd.help.import= — reprendre le design de motd.gg +motd.help.max-players= — le nombre à droite de la barre +motd.help.preview= — l'afficher ici sans rien modifier +motd.help.reset= — le supprimer et utiliser la valeur du proxy +motd.placeholders=Variables : +motd.save-failed=Impossible d'enregistrer le MOTD : +motd.invalid-mini-message=Ce MiniMessage n'est pas valide : +motd.extra-lines=Il y a lignes ; la liste des serveurs en affiche deux. +motd.preview=aperçu : diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt index 9923d93..2684956 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt @@ -1,19 +1,56 @@ package gg.grounds.proxy.velocity.tab import gg.grounds.i18n.BundleCheck +import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.Translations +import gg.grounds.proxy.velocity.ProxySystemMessage +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 ProxyBundleTest { + private val messages = Translations.forBundle(BUNDLE, javaClass.classLoader) + @Test fun `every supported language has every message with the English tags`() { val problems = BundleCheck.problems( - "gg.grounds.proxy.messages", + BUNDLE, javaClass.classLoader, - ProxyMessage.entries, + ProxyMessage.entries + ProxySystemMessage.entries, ) assertEquals(emptyList(), problems, problems.joinToString("\n")) } + + @Test + fun `errors warnings and confirmations use their system categories`() { + assertEquals(SystemCategory.ERROR, ProxySystemMessage.REGION_UNKNOWN.category) + assertEquals(SystemCategory.WARNING, ProxySystemMessage.REGION_ALREADY_HERE.category) + assertEquals(SystemCategory.SUCCESS, ProxySystemMessage.REGION_MOVING.category) + assertEquals(SystemCategory.ERROR, ProxySystemMessage.MOTD_SAVE_FAILED.category) + assertEquals(SystemCategory.WARNING, ProxySystemMessage.MOTD_EXTRA_LINES.category) + assertEquals(SystemCategory.SUCCESS, ProxySystemMessage.MOTD_UPDATED.category) + } + + @Test + fun `command arguments are rendered as literal text`() { + val rendered = + messages.render( + ProxySystemMessage.REGION_UNKNOWN, + Locale.ENGLISH, + "region" to "boom", + "available" to "eu, x", + ) + + assertEquals( + "Unknown region 'boom'. Available: eu, x", + PlainTextComponentSerializer.plainText().serialize(rendered), + ) + } + + private companion object { + const val BUNDLE = "gg.grounds.proxy.messages" + } } From ebcf0cf73207f7fe82182380a3a749b54ff7bd53 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 15 Oct 2026 00:02:22 +0200 Subject: [PATCH 2/3] refactor(chat): separate message context from status level --- .../proxy/velocity/ProxySystemMessage.kt | 60 ++++++++++--------- .../proxy/velocity/tab/ProxyBundleTest.kt | 18 +++--- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt index 757a7bc..87e440b 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt @@ -1,33 +1,35 @@ package gg.grounds.proxy.velocity import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemLevel import gg.grounds.i18n.SystemMessageKey /** Player-facing system chat owned by the proxy. */ enum class ProxySystemMessage( override val id: String, - override val category: SystemCategory = SystemCategory.INFO, + override val category: SystemCategory = SystemCategory.GENERAL, + override val level: SystemLevel = SystemLevel.INFO, ) : SystemMessageKey { - ONLINE_UNAVAILABLE("online.unavailable", SystemCategory.ERROR), + ONLINE_UNAVAILABLE("online.unavailable", level = SystemLevel.ERROR), ONLINE_HEADER("online.header"), ONLINE_NOBODY("online.nobody"), ONLINE_REGION("online.region"), ONLINE_UNKNOWN_REGION("online.region.unknown"), ONLINE_PROXY("online.proxy"), - REGION_PLAYER_ONLY("region.player-only", SystemCategory.ERROR), - REGION_NONE("region.none", SystemCategory.ERROR), - REGION_UNKNOWN("region.unknown", SystemCategory.ERROR), - REGION_ALREADY_HERE("region.already-here", SystemCategory.WARNING), - REGION_UNAVAILABLE("region.unavailable", SystemCategory.ERROR), - REGION_MOVING("region.moving", SystemCategory.SUCCESS), + REGION_PLAYER_ONLY("region.player-only", level = SystemLevel.ERROR), + REGION_NONE("region.none", level = SystemLevel.ERROR), + REGION_UNKNOWN("region.unknown", level = SystemLevel.ERROR), + REGION_ALREADY_HERE("region.already-here", level = SystemLevel.WARNING), + REGION_UNAVAILABLE("region.unavailable", level = SystemLevel.ERROR), + REGION_MOVING("region.moving", level = SystemLevel.SUCCESS), REGION_HEADER("region.header"), REGION_ENTRY("region.entry"), REGION_ENTRY_CURRENT("region.entry.current"), REGION_USAGE("region.usage"), PARTY_TRANSFER_FOLLOWING("party.transfer.following", SystemCategory.PARTY), - MOTD_UNKNOWN_SUBCOMMAND("motd.unknown-subcommand", SystemCategory.ERROR), - MOTD_NOT_SET("motd.not-set", SystemCategory.WARNING), - MOTD_NOT_READ("motd.not-read", SystemCategory.WARNING), + MOTD_UNKNOWN_SUBCOMMAND("motd.unknown-subcommand", level = SystemLevel.ERROR), + MOTD_NOT_SET("motd.not-set", level = SystemLevel.WARNING), + MOTD_NOT_READ("motd.not-read", level = SystemLevel.WARNING), MOTD_HEADER("motd.header"), MOTD_SOURCE("motd.source"), MOTD_CHANGED_BY("motd.changed-by"), @@ -35,23 +37,23 @@ enum class ProxySystemMessage( MOTD_MAX_PLAYERS("motd.max-players"), MOTD_MAX_PLAYERS_DEFAULT("motd.max-players.default"), MOTD_RAW("motd.raw"), - MOTD_USAGE_SET("motd.usage.set", SystemCategory.WARNING), - MOTD_UPDATED("motd.updated", SystemCategory.SUCCESS), - MOTD_USAGE_IMPORT("motd.usage.import", SystemCategory.WARNING), + MOTD_USAGE_SET("motd.usage.set", level = SystemLevel.WARNING), + MOTD_UPDATED("motd.updated", level = SystemLevel.SUCCESS), + MOTD_USAGE_IMPORT("motd.usage.import", level = SystemLevel.WARNING), MOTD_FETCHING("motd.fetching"), - MOTD_IMPORT_FAILED("motd.import-failed", SystemCategory.ERROR), - MOTD_ICON_SKIPPED("motd.icon-skipped", SystemCategory.WARNING), - MOTD_IMPORTED("motd.imported", SystemCategory.SUCCESS), - MOTD_IMPORTED_NAMED("motd.imported.named", SystemCategory.SUCCESS), - MOTD_REQUIRED_FOR_MAX_PLAYERS("motd.max-players.required", SystemCategory.ERROR), - MOTD_USAGE_MAX_PLAYERS("motd.usage.max-players", SystemCategory.WARNING), - MOTD_INVALID_PLAYER_COUNT("motd.invalid-player-count", SystemCategory.ERROR), - MOTD_MAX_PLAYERS_CLEARED("motd.max-players.cleared", SystemCategory.SUCCESS), - MOTD_MAX_PLAYERS_SET("motd.max-players.set", SystemCategory.SUCCESS), - MOTD_REMOVED("motd.removed", SystemCategory.SUCCESS), + MOTD_IMPORT_FAILED("motd.import-failed", level = SystemLevel.ERROR), + MOTD_ICON_SKIPPED("motd.icon-skipped", level = SystemLevel.WARNING), + MOTD_IMPORTED("motd.imported", level = SystemLevel.SUCCESS), + MOTD_IMPORTED_NAMED("motd.imported.named", level = SystemLevel.SUCCESS), + MOTD_REQUIRED_FOR_MAX_PLAYERS("motd.max-players.required", level = SystemLevel.ERROR), + MOTD_USAGE_MAX_PLAYERS("motd.usage.max-players", level = SystemLevel.WARNING), + MOTD_INVALID_PLAYER_COUNT("motd.invalid-player-count", level = SystemLevel.ERROR), + MOTD_MAX_PLAYERS_CLEARED("motd.max-players.cleared", level = SystemLevel.SUCCESS), + MOTD_MAX_PLAYERS_SET("motd.max-players.set", level = SystemLevel.SUCCESS), + MOTD_REMOVED("motd.removed", level = SystemLevel.SUCCESS), MOTD_NOTHING_TO_REMOVE("motd.nothing-to-remove"), - MOTD_REMOVE_FAILED("motd.remove-failed", SystemCategory.ERROR), - MOTD_NOTHING_TO_PREVIEW("motd.nothing-to-preview", SystemCategory.ERROR), + MOTD_REMOVE_FAILED("motd.remove-failed", level = SystemLevel.ERROR), + MOTD_NOTHING_TO_PREVIEW("motd.nothing-to-preview", level = SystemLevel.ERROR), MOTD_HELP_HEADER("motd.help.header"), MOTD_HELP_SHOW("motd.help.show"), MOTD_HELP_SET("motd.help.set"), @@ -60,8 +62,8 @@ enum class ProxySystemMessage( MOTD_HELP_PREVIEW("motd.help.preview"), MOTD_HELP_RESET("motd.help.reset"), MOTD_PLACEHOLDERS("motd.placeholders"), - MOTD_SAVE_FAILED("motd.save-failed", SystemCategory.ERROR), - MOTD_INVALID_MINI_MESSAGE("motd.invalid-mini-message", SystemCategory.ERROR), - MOTD_EXTRA_LINES("motd.extra-lines", SystemCategory.WARNING), + MOTD_SAVE_FAILED("motd.save-failed", level = SystemLevel.ERROR), + MOTD_INVALID_MINI_MESSAGE("motd.invalid-mini-message", level = SystemLevel.ERROR), + MOTD_EXTRA_LINES("motd.extra-lines", level = SystemLevel.WARNING), MOTD_PREVIEW("motd.preview"), } diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt index 2684956..bf82a12 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/ProxyBundleTest.kt @@ -2,6 +2,7 @@ package gg.grounds.proxy.velocity.tab import gg.grounds.i18n.BundleCheck import gg.grounds.i18n.SystemCategory +import gg.grounds.i18n.SystemLevel import gg.grounds.i18n.Translations import gg.grounds.proxy.velocity.ProxySystemMessage import java.util.Locale @@ -25,13 +26,16 @@ class ProxyBundleTest { } @Test - fun `errors warnings and confirmations use their system categories`() { - assertEquals(SystemCategory.ERROR, ProxySystemMessage.REGION_UNKNOWN.category) - assertEquals(SystemCategory.WARNING, ProxySystemMessage.REGION_ALREADY_HERE.category) - assertEquals(SystemCategory.SUCCESS, ProxySystemMessage.REGION_MOVING.category) - assertEquals(SystemCategory.ERROR, ProxySystemMessage.MOTD_SAVE_FAILED.category) - assertEquals(SystemCategory.WARNING, ProxySystemMessage.MOTD_EXTRA_LINES.category) - assertEquals(SystemCategory.SUCCESS, ProxySystemMessage.MOTD_UPDATED.category) + fun `proxy feedback keeps general context and uses semantic levels`() { + assertEquals(SystemCategory.GENERAL, ProxySystemMessage.REGION_UNKNOWN.category) + assertEquals(SystemLevel.ERROR, ProxySystemMessage.REGION_UNKNOWN.level) + assertEquals(SystemLevel.WARNING, ProxySystemMessage.REGION_ALREADY_HERE.level) + assertEquals(SystemLevel.SUCCESS, ProxySystemMessage.REGION_MOVING.level) + assertEquals(SystemLevel.ERROR, ProxySystemMessage.MOTD_SAVE_FAILED.level) + assertEquals(SystemLevel.WARNING, ProxySystemMessage.MOTD_EXTRA_LINES.level) + assertEquals(SystemLevel.SUCCESS, ProxySystemMessage.MOTD_UPDATED.level) + assertEquals(SystemCategory.PARTY, ProxySystemMessage.PARTY_TRANSFER_FOLLOWING.category) + assertEquals(SystemLevel.INFO, ProxySystemMessage.PARTY_TRANSFER_FOLLOWING.level) } @Test From 5e5d8631aeda3bd09e84d396c0e6a4d23c8e92db Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 15 Oct 2026 00:15:12 +0200 Subject: [PATCH 3/3] build(chat): consume published shared chat APIs --- velocity/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 6e5dd72..2741153 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -19,7 +19,7 @@ dependencies { implementation("com.google.code.gson:gson:2.14.0") // The tab list header and footer are player-facing text, so they come from a bundle and are // drawn in the design tokens, like every other line the network shows a player. - implementation("gg.grounds:library-i18n:0.3.0") + implementation("gg.grounds:library-i18n:0.4.0") implementation("io.nats:jnats:2.26.4") // The metrics endpoint. Micrometer rather than a hand-written exposition format, because its