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
8 changes: 7 additions & 1 deletion api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
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")
}
Original file line number Diff line number Diff line change
@@ -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,
)
24 changes: 24 additions & 0 deletions api/src/main/kotlin/gg/grounds/proxy/api/PlayerMessageRenderer.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ object ProxyServiceRegistry {
services[serviceClass] = instance
}

/** Publishes only when no provider already owns [serviceClass]. */
fun <T : Any> registerIfAbsent(serviceClass: Class<T>, instance: T): Boolean =
services.putIfAbsent(serviceClass, instance) == null

/** The registered implementation, or null when no plugin provides it. */
fun <T : Any> get(serviceClass: Class<T>): T? {
return serviceClass.cast(services[serviceClass])
Expand All @@ -48,4 +52,16 @@ object ProxyServiceRegistry {
fun <T : Any> unregister(serviceClass: Class<T>) {
services.remove(serviceClass)
}

/** Withdraws [instance] only while it still owns [serviceClass]. */
fun <T : Any> unregister(serviceClass: Class<T>, instance: T): Boolean {
var removed = false
services.computeIfPresent(serviceClass) { _, current ->
if (current === instance) {
removed = true
null
} else current
}
return removed
}
}
Original file line number Diff line number Diff line change
@@ -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>(
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")
}
}
2 changes: 1 addition & 1 deletion velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<Subscription>()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
)
}
}

Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.GENERAL,
override val level: SystemLevel = SystemLevel.INFO,
) : SystemMessageKey {
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", 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", 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"),
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", 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", 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", 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"),
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", 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"),
}
Loading