From 6215b9cb23045ac47d7ae70c8226ae2f7eabe5ed 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): unify localized resourcepacks messages --- build.gradle.kts | 3 + velocity/build.gradle.kts | 12 + velocity/gradle.lockfile | 2 + .../velocity/GroundsResourcePacksPlugin.kt | 140 +++++++- .../velocity/PlayerChatAppearance.kt | 204 +++++++++++ .../velocity/PlayerChatStyleBridge.kt | 89 +++++ .../velocity/ResourcePackCoordinator.kt | 8 + .../velocity/ResourcePackStatusListener.kt | 4 +- .../velocity/ResourcePacksMessage.kt | 7 + .../velocity/VelocityPackRequestFactory.kt | 7 + .../grounds/resourcepacks/messages.properties | 1 + .../resourcepacks/messages_de.properties | 1 + .../velocity/AutomationContractTest.kt | 15 +- .../GroundsResourcePacksPluginTest.kt | 336 +++++++++++++++++- .../velocity/PlayerChatAppearanceTest.kt | 177 +++++++++ .../velocity/PlayerChatStyleBridgeTest.kt | 167 +++++++++ .../velocity/ResourcePackCoordinatorTest.kt | 23 ++ .../velocity/ResourcePackTestFixtures.kt | 4 +- .../velocity/ResourcePacksMessagesTest.kt | 26 ++ 19 files changed, 1198 insertions(+), 28 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearance.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessage.kt create mode 100644 velocity/src/main/resources/gg/grounds/resourcepacks/messages.properties create mode 100644 velocity/src/main/resources/gg/grounds/resourcepacks/messages_de.properties create mode 100644 velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearanceTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridgeTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 11112e9..e491d1f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -83,6 +83,9 @@ subprojects { check("\"id\":\"plugin-config\",\"optional\":false" in metadata) { "plugin metadata must require plugin-config" } + check("\"id\":\"plugin-proxy\",\"optional\":false" in metadata) { + "plugin metadata must require plugin-proxy" + } } } } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 8b4780c..29c3efe 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -6,6 +6,8 @@ configure { packageName("gg.grounds.generated") } dependencies { compileOnly("gg.grounds:plugin-config-common:1.0.0") + compileOnly("gg.grounds:plugin-proxy-api:2.2.0") + implementation("gg.grounds:library-i18n:0.3.0") compileOnly("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( @@ -17,6 +19,7 @@ dependencies { implementation("gg.grounds:resourcepacks-client:1.0.0") testImplementation("gg.grounds:plugin-config-common:1.0.0") + testImplementation("gg.grounds:plugin-proxy-api:2.2.0") testImplementation("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( @@ -29,3 +32,12 @@ dependencies { testImplementation("org.yaml:snakeyaml:2.6") testImplementation(kotlin("test")) } + +val groundsGradleInitScript = providers.environmentVariable("GROUNDS_GRADLE_INIT_SCRIPT") + +if (groundsGradleInitScript.isPresent) { + tasks.named("test") { + dependsOn(tasks.named("shadowJar")) + environment("GROUNDS_GRADLE_INIT_SCRIPT", groundsGradleInitScript.get()) + } +} diff --git a/velocity/gradle.lockfile b/velocity/gradle.lockfile index adfb246..04fbdef 100644 --- a/velocity/gradle.lockfile +++ b/velocity/gradle.lockfile @@ -23,8 +23,10 @@ com.moandjiezana.toml:toml4j:0.7.2=compileClasspath,kapt,kaptClasspath_kaptKotli com.soywiz.korlibs.korte:korte-jvm:4.0.10=dokkaJavadocGeneratorRuntimeResolver~internal com.velocitypowered:velocity-api:3.5.0-SNAPSHOT=compileClasspath,kapt,kaptClasspath_kaptKotlin,kaptClasspath_kaptTestKotlin,kaptTest,testCompileClasspath,testRuntimeClasspath com.velocitypowered:velocity-brigadier:1.0.0-SNAPSHOT=compileClasspath,kapt,kaptClasspath_kaptKotlin,kaptClasspath_kaptTestKotlin,kaptTest,testCompileClasspath,testRuntimeClasspath +gg.grounds:library-i18n:0.3.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:plugin-config-common:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:plugin-config-velocity:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +gg.grounds:plugin-proxy-api:2.2.0=compileClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:resourcepacks-client:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:resourcepacks-contract:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt index 8cb5755..d50c14f 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt @@ -4,6 +4,8 @@ import com.google.inject.Inject import com.velocitypowered.api.event.EventTask import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PluginMessageEvent +import com.velocitypowered.api.event.player.PlayerResourcePackStatusEvent import com.velocitypowered.api.event.player.ServerPostConnectEvent import com.velocitypowered.api.event.player.configuration.PlayerConfigurationEvent import com.velocitypowered.api.event.proxy.ProxyInitializeEvent @@ -14,6 +16,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.config.ConfigStartupMode import gg.grounds.generated.BuildInfo +import gg.grounds.i18n.Translations import gg.grounds.resourcepacks.client.PackSetClientState import gg.grounds.resourcepacks.client.PackSetClientStatus import gg.grounds.resourcepacks.client.PackSetSelection @@ -23,7 +26,6 @@ import java.nio.file.Path import java.util.concurrent.CompletableFuture import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -import net.kyori.adventure.text.Component import org.slf4j.Logger @Plugin( @@ -33,7 +35,12 @@ import org.slf4j.Logger description = "Delivers ordered resource-pack requests to players on login", authors = ["Grounds Development Team and contributors"], url = "https://github.com/groundsgg/plugin-resourcepacks", - dependencies = [Dependency(id = "plugin-config")], + dependencies = + [ + Dependency(id = "plugin-config"), + Dependency(id = "plugin-proxy"), + Dependency(id = "floodgate", optional = true), + ], ) class GroundsResourcePacksPlugin internal constructor( @@ -46,6 +53,14 @@ internal constructor( private val log: ResourcePackLog, private val snapshotDeadline: ResourcePackSnapshotDeadline = ScheduledResourcePackSnapshotDeadline(), + private val appearanceProtocol: + (java.util.UUID) -> com.velocitypowered.api.network.ProtocolVersion? = + { + null + }, + private val platformLookup: ChatPlatformLookup = ChatPlatformLookup { null }, + private val appearanceRegistry: PlayerChatAppearanceRegistry = NoopPlayerChatAppearanceRegistry, + private val chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, ) { @Inject constructor( @@ -60,6 +75,16 @@ internal constructor( VelocityPlayerPackSender, VelocityResourcePackEventRegistry(proxy), Slf4jResourcePackLog(logger), + appearanceProtocol = { playerId -> + proxy.getPlayer(playerId).orElse(null)?.protocolVersion + }, + platformLookup = FloodgateChatPlatformLookup.create(logger), + appearanceRegistry = ProxyPlayerChatAppearanceRegistry, + chatStyleBridge = + VelocityPlayerChatStyleBridge( + registerChannel = { proxy.channelRegistrar.register(it) }, + unregisterChannel = { proxy.channelRegistrar.unregister(it) }, + ), ) private val lifecycle = Any() @@ -70,6 +95,9 @@ internal constructor( private val state = AtomicReference(closedState()) private val lastLoggedStatus = AtomicReference(null) private val configurationWaiter = ResourcePackConfigurationWaiter() + private val appearanceTracker = AtomicReference(null) + private val messages = + Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader) private val deadlines = Any() private val initialDeadlines = HashMap() private val coordinator = @@ -84,6 +112,8 @@ internal constructor( requestFactory = VelocityPackRequestFactory(), deliveryObserver = ResourcePackDeliveryObserver { player, prepared -> + appearanceTracker.get()?.offered(player.uniqueId, prepared.packHashes) + publishChatStyle(player) log.info( "Resource-pack request sent (playerId=${player.uniqueId}, " + "targetId=${prepared.targetId}, fingerprint=${prepared.fingerprint}, " + @@ -106,23 +136,54 @@ internal constructor( fun onInitialize(@Suppress("UNUSED_PARAMETER") event: ProxyInitializeEvent) { if (!initialized.compareAndSet(false, true)) return val deployment = environment() + val tracker = + PlayerChatAppearanceTracker(ChatAppearanceEnvironment.from(deployment).fontPackSha1s) + appearanceTracker.set(tracker) + appearanceRegistry.register( + ResourcePackPlayerChatAppearanceQuery(appearanceProtocol, platformLookup, tracker) + ) + chatStyleBridge.register() val definition = resourcePackSettingsDefinition(bootstrapPackSetChannel(deployment)) val deploymentEnvironment = ResourcePackEnvironment.from(deployment).deploymentEnvironment val listener = ResourcePackStatusListener( metrics, - coordinator::ownsPack, + { player, packId -> coordinator.ownsPack(player, packId) }, coordinator::targetId, log, - { player, packId, status -> + statusObserver@{ player, packId, status -> val playerId = player.uniqueId - val initial = + val observed = synchronized(deadlines) { - initialDeadlines[playerId]?.takeIf { it.player === player } - } - initial?.let { - configurationWaiter.onStatus(playerId, packId, status, it.completion) - } + if (stopped.get()) return@synchronized null + val initial = + initialDeadlines[playerId]?.takeIf { it.player === player } + ?: return@synchronized null + val publishAppearance = + when (status) { + PlayerResourcePackStatusEvent.Status.SUCCESSFUL -> { + if (packId != null) tracker.loaded(playerId, packId) + true + } + PlayerResourcePackStatusEvent.Status.ACCEPTED, + PlayerResourcePackStatusEvent.Status.DOWNLOADED -> false + else -> { + if (packId != null) tracker.failed(playerId, packId) + true + } + } + ObservedPackStatus( + initial, + if (publishAppearance) captureChatStyle(player) else null, + ) + } ?: return@statusObserver + observed.appearance?.let { chatStyleBridge.send(player, it) } + configurationWaiter.onStatus( + playerId, + packId, + status, + observed.initial.completion, + ) }, ) statusListener = listener @@ -164,6 +225,9 @@ internal constructor( previous = configurationWaiter.begin(playerId, completion) replacedHandle = synchronized(deadlines) { + if (initialDeadlines[playerId]?.player !== event.player()) { + appearanceTracker.get()?.reset(playerId) + } initialDeadlines.put(playerId, initial)?.handle } registered = true @@ -192,11 +256,21 @@ internal constructor( @Subscribe fun onDisconnect(event: DisconnectEvent) { - val initial = + val disconnected = synchronized(deadlines) { - initialDeadlines[event.player.uniqueId]?.takeIf { it.player === event.player } + val initial = + initialDeadlines[event.player.uniqueId]?.takeIf { it.player === event.player } + ?: return@synchronized null + initialDeadlines.remove(event.player.uniqueId) + DisconnectedPlayer( + initial, + appearanceTracker.get()?.disconnectSnapshot(event.player.uniqueId), + ) } ?: return - forgetInitial(initial) + disconnected.appearance?.let { snapshot -> chatStyleBridge.send(event.player, snapshot) } + disconnected.initial.handle?.close() + coordinator.forget(event.player.uniqueId, disconnected.initial.session) + configurationWaiter.forget(event.player.uniqueId, disconnected.initial.completion) } @Subscribe @@ -210,6 +284,14 @@ internal constructor( initial.completion.isDone ) coordinator.onServerSwitch(event.player, initial.session) + if (!stopped.get()) publishChatStyle(event.player) + } + + @Subscribe + fun onPluginMessage(event: PluginMessageEvent) { + if (event.identifier == chatStyleBridge.identifier) { + event.result = PluginMessageEvent.ForwardResult.handled() + } } @Subscribe @@ -220,6 +302,9 @@ internal constructor( var statusToUnregister: ResourcePackStatusListener? = null var clientToClose: ResourcePackClient? = null coordinator.clear() + appearanceTracker.getAndSet(null)?.clear() + appearanceRegistry.unregister() + chatStyleBridge.unregister() snapshotDeadline.close() synchronized(deadlines) { initialDeadlines.values.forEach { it.handle?.close() } @@ -244,6 +329,23 @@ internal constructor( state.set(closedState()) } + private fun publishChatStyle(player: com.velocitypowered.api.proxy.Player) { + val snapshot = captureChatStyle(player) ?: return + chatStyleBridge.send(player, snapshot) + } + + private fun captureChatStyle( + player: com.velocitypowered.api.proxy.Player + ): PlayerChatStyleSnapshot? = + synchronized(deadlines) { + if (stopped.get()) return@synchronized null + if (initialDeadlines[player.uniqueId]?.player !== player) return@synchronized null + appearanceTracker.get()?.snapshot(player.uniqueId) { + appearanceProtocol(player.uniqueId) != null && + platformLookup.isBedrock(player.uniqueId) == false + } + } + fun runtimeStatus(): ResourcePackRuntimeStatus { val value = state.get() val counts = metrics.snapshot() @@ -370,7 +472,7 @@ internal constructor( } try { session.player.disconnect( - Component.text("Resource packs are currently unavailable. Please try again.") + messages.render(ResourcePacksMessage.PACKS_UNAVAILABLE, session.player) ) } finally { configurationWaiter.forget(playerId, session.completion) @@ -399,6 +501,16 @@ internal constructor( var ready = false } + private data class ObservedPackStatus( + val initial: InitialDeadline, + val appearance: PlayerChatStyleSnapshot?, + ) + + private data class DisconnectedPlayer( + val initial: InitialDeadline, + val appearance: PlayerChatStyleSnapshot?, + ) + private companion object { val LOGGED_STATES = setOf( diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearance.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearance.kt new file mode 100644 index 0000000..65722af --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearance.kt @@ -0,0 +1,204 @@ +package gg.grounds.resourcepacks.velocity + +import com.velocitypowered.api.network.ProtocolVersion +import gg.grounds.proxy.api.PlayerChatAppearance +import gg.grounds.proxy.api.PlayerChatAppearanceQuery +import gg.grounds.proxy.api.ProxyServiceRegistry +import java.lang.reflect.Method +import java.util.UUID +import org.slf4j.Logger + +internal data class ChatAppearanceEnvironment(val fontPackSha1s: Set) { + companion object { + private val SHA1 = Regex("[0-9a-f]{40}") + + fun from(environment: Map): ChatAppearanceEnvironment { + val raw = environment["CHAT_FONT_PACK_SHA1S"].orEmpty() + if (raw.isBlank()) return ChatAppearanceEnvironment(emptySet()) + val values = raw.split(',').map(String::trim) + require(values.all(SHA1::matches)) { + "CHAT_FONT_PACK_SHA1S must contain only comma-separated lowercase SHA-1 values" + } + return ChatAppearanceEnvironment(values.toSet()) + } + } +} + +internal data class PlayerChatStyleSnapshot(val revision: Long, val ready: Boolean) + +internal class PlayerChatAppearanceTracker(private val allowedHashes: Set) { + private val lock = Any() + private val players = HashMap() + private var revision = 0L + + fun offered(playerId: UUID, packs: Map) { + synchronized(lock) { + val state = players.getOrPut(playerId, ::PlayerState) + state.current = packs + state.loaded.clear() + packs.forEach { (packId, hash) -> + if (hash == null) { + state.ambiguous += packId + } else { + val previous = state.seenHashes.putIfAbsent(packId, hash) + if (previous != null && previous != hash) state.ambiguous += packId + } + } + } + } + + fun loaded(playerId: UUID, packId: UUID) { + synchronized(lock) { + val state = players[playerId] ?: return@synchronized + val hash = state.current[packId] ?: return@synchronized + if (packId !in state.ambiguous && hash in allowedHashes) state.loaded += packId + } + } + + fun failed(playerId: UUID, packId: UUID) { + synchronized(lock) { + val state = players[playerId] ?: return@synchronized + if (packId in state.current) state.loaded -= packId + } + } + + fun forget(playerId: UUID) { + synchronized(lock) { players.remove(playerId) } + } + + fun reset(playerId: UUID) { + synchronized(lock) { players.remove(playerId) } + } + + fun disconnectSnapshot(playerId: UUID): PlayerChatStyleSnapshot = + synchronized(lock) { + players.remove(playerId) + revision = nextRevision(revision) + PlayerChatStyleSnapshot(revision, ready = false) + } + + fun clear() { + synchronized(lock) { players.clear() } + } + + fun chatFontReady(playerId: UUID): Boolean = + synchronized(lock) { players[playerId]?.loaded?.isNotEmpty() == true } + + fun snapshot(playerId: UUID, eligible: () -> Boolean): PlayerChatStyleSnapshot = + synchronized(lock) { + val state = players.getOrPut(playerId, ::PlayerState) + revision = nextRevision(revision) + PlayerChatStyleSnapshot(revision, state.loaded.isNotEmpty() && eligible()) + } + + private fun nextRevision(current: Long): Long { + check(current != Long.MAX_VALUE) { "chat style revision exhausted" } + return current + 1 + } + + private class PlayerState { + var current: Map = emptyMap() + val loaded = mutableSetOf() + val seenHashes = mutableMapOf() + val ambiguous = mutableSetOf() + } +} + +internal fun interface ChatPlatformLookup { + /** true for Bedrock, false for Java, null when the platform could not be determined. */ + fun isBedrock(playerId: UUID): Boolean? +} + +internal class SafeChatPlatformLookup( + private val lookup: (UUID) -> Boolean, + private val onFailure: (Exception) -> Unit, +) : ChatPlatformLookup { + @Volatile private var failed = false + + override fun isBedrock(playerId: UUID): Boolean? { + if (failed) return null + return try { + lookup(playerId) + } catch (failure: Exception) { + failed = true + onFailure(failure) + null + } + } +} + +internal object FloodgateChatPlatformLookup { + private const val API = "org.geysermc.floodgate.api.FloodgateApi" + + fun create(logger: Logger): ChatPlatformLookup = + try { + val api = Class.forName(API) + val getInstance = api.getMethod("getInstance") + val isFloodgatePlayer = api.getMethod("isFloodgatePlayer", UUID::class.java) + reflective(logger, getInstance, isFloodgatePlayer) + } catch (_: ClassNotFoundException) { + ChatPlatformLookup { false } + } catch (failure: NoSuchMethodException) { + logger.warn("Floodgate is installed but {} is not the expected shape", API, failure) + ChatPlatformLookup { null } + } + + private fun reflective( + logger: Logger, + getInstance: Method, + isFloodgatePlayer: Method, + ): ChatPlatformLookup = + SafeChatPlatformLookup( + lookup = { playerId -> + val api = + getInstance.invoke(null) + ?: throw ReflectiveOperationException( + "Floodgate API instance is unavailable" + ) + isFloodgatePlayer.invoke(api, playerId) as Boolean + }, + onFailure = { failure -> + logger.warn( + "Floodgate lookup failed; chat appearance will stay text-only from here on", + failure, + ) + }, + ) +} + +internal class ResourcePackPlayerChatAppearanceQuery( + private val protocol: (UUID) -> ProtocolVersion?, + private val platform: ChatPlatformLookup, + private val tracker: PlayerChatAppearanceTracker, +) : PlayerChatAppearanceQuery { + override fun appearanceOf(playerId: UUID): PlayerChatAppearance { + val protocol = protocol(playerId) ?: return PlayerChatAppearance(false, false) + if (platform.isBedrock(playerId) != false) return PlayerChatAppearance(false, false) + return PlayerChatAppearance( + javaHeadsSupported = protocol >= ProtocolVersion.MINECRAFT_1_21_9, + chatFontReady = tracker.chatFontReady(playerId), + ) + } +} + +internal interface PlayerChatAppearanceRegistry { + fun register(query: PlayerChatAppearanceQuery) + + fun unregister() +} + +internal object ProxyPlayerChatAppearanceRegistry : PlayerChatAppearanceRegistry { + override fun register(query: PlayerChatAppearanceQuery) { + ProxyServiceRegistry.register(PlayerChatAppearanceQuery::class.java, query) + } + + override fun unregister() { + ProxyServiceRegistry.unregister(PlayerChatAppearanceQuery::class.java) + } +} + +internal object NoopPlayerChatAppearanceRegistry : PlayerChatAppearanceRegistry { + override fun register(query: PlayerChatAppearanceQuery) = Unit + + override fun unregister() = Unit +} diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt new file mode 100644 index 0000000..140dc30 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt @@ -0,0 +1,89 @@ +package gg.grounds.resourcepacks.velocity + +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.messages.ChannelIdentifier +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID + +internal object PlayerChatStylePayload { + const val CHANNEL = "grounds:chat_style" + const val VERSION: Byte = 1 + const val SIZE = 26 + + fun encode(playerId: UUID, snapshot: PlayerChatStyleSnapshot): ByteArray { + require(snapshot.revision > 0) { "chat style revision must be positive" } + return ByteBuffer.allocate(SIZE) + .order(ByteOrder.BIG_ENDIAN) + .put(VERSION) + .putLong(playerId.mostSignificantBits) + .putLong(playerId.leastSignificantBits) + .putLong(snapshot.revision) + .put(if (snapshot.ready) 1.toByte() else 0.toByte()) + .array() + } +} + +internal interface PlayerChatStyleBridge { + val identifier: ChannelIdentifier + + fun register() + + fun unregister() + + fun send(player: Player, snapshot: PlayerChatStyleSnapshot) +} + +internal class VelocityPlayerChatStyleBridge( + private val registerChannel: (ChannelIdentifier) -> Unit, + private val unregisterChannel: (ChannelIdentifier) -> Unit, + private val currentServer: (Player) -> ServerConnection? = { player -> + player.currentServer.orElse(null) + }, +) : PlayerChatStyleBridge { + private val lifecycle = Any() + private var active = false + + override val identifier: ChannelIdentifier = + MinecraftChannelIdentifier.from(PlayerChatStylePayload.CHANNEL) + + override fun register() { + synchronized(lifecycle) { + if (active) return + registerChannel(identifier) + active = true + } + } + + override fun unregister() { + synchronized(lifecycle) { + if (!active) return + unregisterChannel(identifier) + active = false + } + } + + override fun send(player: Player, snapshot: PlayerChatStyleSnapshot) { + synchronized(lifecycle) { + if (!active) return + val server = currentServer(player) ?: return + server.sendPluginMessage( + identifier, + PlayerChatStylePayload.encode(player.uniqueId, snapshot), + ) + } + } +} + +internal object NoopPlayerChatStyleBridge : PlayerChatStyleBridge { + override val identifier: ChannelIdentifier = + MinecraftChannelIdentifier.from(PlayerChatStylePayload.CHANNEL) + + override fun register() = Unit + + override fun unregister() = Unit + + override fun send(player: Player, snapshot: PlayerChatStyleSnapshot) = Unit +} diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinator.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinator.kt index 4f43a11..e7ab81f 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinator.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinator.kt @@ -190,6 +190,14 @@ internal class ResourcePackCoordinator( targetAttributions.containsKey(PlayerPack(playerId, packId))) } + internal fun ownsPack(player: Player, packId: UUID?): Boolean = + synchronized(delivery) { + val current = + pendingInitial[player.uniqueId]?.player + ?: initiallyCompleted[player.uniqueId]?.player + current === player && ownsPack(player.uniqueId, packId) + } + internal fun clear() = synchronized(delivery) { closed = true diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackStatusListener.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackStatusListener.kt index 3d03694..6898903 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackStatusListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackStatusListener.kt @@ -7,7 +7,7 @@ import java.util.UUID internal class ResourcePackStatusListener( private val metrics: ResourcePackMetrics, - private val ownsPack: (UUID, UUID?) -> Boolean, + private val ownsPack: (Player, UUID?) -> Boolean, private val targetId: (UUID, UUID?) -> String?, private val log: ResourcePackLog, private val statusObserver: (Player, UUID?, PlayerResourcePackStatusEvent.Status) -> Unit = @@ -16,7 +16,7 @@ internal class ResourcePackStatusListener( ) { @Subscribe fun onStatus(event: PlayerResourcePackStatusEvent) { - if (!ownsPack(event.player.uniqueId, event.packId)) return + if (!ownsPack(event.player, event.packId)) return statusObserver(event.player, event.packId, event.status) metrics.record(event.status) log.info( diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessage.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessage.kt new file mode 100644 index 0000000..a347869 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessage.kt @@ -0,0 +1,7 @@ +package gg.grounds.resourcepacks.velocity + +import gg.grounds.i18n.MessageKey + +internal enum class ResourcePacksMessage(override val id: String) : MessageKey { + PACKS_UNAVAILABLE("error.packs-unavailable") +} diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/VelocityPackRequestFactory.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/VelocityPackRequestFactory.kt index 238d10d..d07938e 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/VelocityPackRequestFactory.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/VelocityPackRequestFactory.kt @@ -25,6 +25,11 @@ class VelocityPackRequestFactory { snapshot.packs.sortedBy(ResolvedPack::order).map { pack -> ResourcePackInfo.resourcePackInfo(pack.uuid, pack.uri, pack.sha1) } + val packHashes = + snapshot.packs + .filter { it.role == "platform" } + .groupBy(ResolvedPack::uuid) + .mapValues { (_, packs) -> packs.map(ResolvedPack::sha1).distinct().singleOrNull() } return PreparedPackRequest( ResourcePackRequest.resourcePackRequest() .packs(infos) @@ -34,6 +39,7 @@ class VelocityPackRequestFactory { "${snapshot.source.cacheKey}:${snapshot.fingerprint}:${settings.required}:${settings.prompt}", snapshot.publication.id, snapshot.packs.mapTo(linkedSetOf(), ResolvedPack::uuid), + packHashes, ) } @@ -57,4 +63,5 @@ internal data class PreparedPackRequest( val fingerprint: String, val targetId: String, val packIds: Set, + val packHashes: Map, ) diff --git a/velocity/src/main/resources/gg/grounds/resourcepacks/messages.properties b/velocity/src/main/resources/gg/grounds/resourcepacks/messages.properties new file mode 100644 index 0000000..79f6ee5 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/resourcepacks/messages.properties @@ -0,0 +1 @@ +error.packs-unavailable=Resource packs are currently unavailable. Please try again. diff --git a/velocity/src/main/resources/gg/grounds/resourcepacks/messages_de.properties b/velocity/src/main/resources/gg/grounds/resourcepacks/messages_de.properties new file mode 100644 index 0000000..9f5a8b9 --- /dev/null +++ b/velocity/src/main/resources/gg/grounds/resourcepacks/messages_de.properties @@ -0,0 +1 @@ +error.packs-unavailable=Ressourcenpakete sind derzeit nicht verfügbar. Bitte versuche es erneut. diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/AutomationContractTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/AutomationContractTest.kt index fcb0a86..e835cbb 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/AutomationContractTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/AutomationContractTest.kt @@ -438,8 +438,10 @@ class AutomationContractTest { @Test fun `production metadata verifier rejects a mutated shaded archive`() { - val shadowBuild = runGradle(":velocity:shadowJar") - assertEquals(0, shadowBuild.exitCode, shadowBuild.output) + if (System.getenv("GROUNDS_GRADLE_INIT_SCRIPT").isNullOrBlank()) { + val shadowBuild = runGradle(":velocity:shadowJar") + assertEquals(0, shadowBuild.exitCode, shadowBuild.output) + } val archive = root.resolve("velocity/build/libs/plugin-resourcepacks-velocity.jar") assertTrue(archive.exists(), "shadowJar did not produce $archive") @@ -500,9 +502,18 @@ class AutomationContractTest { private fun runGradle(vararg arguments: String): GradleRun { val outputFile = Files.createTempFile(tempDir, "gradle-", ".log") + val initScript = + System.getenv("GROUNDS_GRADLE_INIT_SCRIPT")?.takeIf(String::isNotBlank)?.let(Path::of) + if (initScript != null) { + require(Files.isRegularFile(initScript)) { + "GROUNDS_GRADLE_INIT_SCRIPT must name an existing regular file" + } + } + val compositeArguments = initScript?.let { listOf("-I", it.toString()) }.orEmpty() val process = ProcessBuilder( listOf(root.resolve("gradlew").toString(), "--no-daemon", "--offline") + + compositeArguments + arguments ) .directory(root.toFile()) diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt index 411e32b..431b9c0 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt @@ -3,19 +3,23 @@ package gg.grounds.resourcepacks.velocity import com.velocitypowered.api.event.Continuation import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PluginMessageEvent import com.velocitypowered.api.event.connection.PostLoginEvent import com.velocitypowered.api.event.player.PlayerResourcePackStatusEvent import com.velocitypowered.api.event.player.ServerPostConnectEvent import com.velocitypowered.api.event.player.configuration.PlayerConfigurationEvent import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyShutdownEvent +import com.velocitypowered.api.network.ProtocolVersion import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ServerConnection import gg.grounds.config.ConfigDefinition import gg.grounds.config.ConfigDefinitionNotReadyException import gg.grounds.config.ConfigRegistrationResult import gg.grounds.config.ConfigStartupMode import gg.grounds.generated.BuildInfo +import gg.grounds.proxy.api.PlayerChatAppearanceQuery import gg.grounds.resourcepacks.client.PackSetClientState import gg.grounds.resourcepacks.client.PackSetClientStatus import gg.grounds.resourcepacks.client.PackSetSelection @@ -47,7 +51,7 @@ class GroundsResourcePacksPluginTest { assertEquals("plugin-resourcepacks", annotation.id) assertEquals(BuildInfo.VERSION, annotation.version) assertEquals( - listOf("plugin-config" to false), + listOf("plugin-config" to false, "plugin-proxy" to false, "floodgate" to true), annotation.dependencies.map { it.id to it.optional }, ) } @@ -82,6 +86,174 @@ class GroundsResourcePacksPluginTest { assertSame(request, received) } + @Test + fun `appearance query is registered loaded and withdrawn with plugin lifecycle`() { + val initial = settings() + val gateway = FakeConfigGateway(ConfigRegistrationResult.ready(), initial) + val clients = FakeClientFactory() + val events = FakeEventRegistry() + val registry = RecordingPlayerChatAppearanceRegistry() + val bridge = RecordingPlayerChatStyleBridge(connected = false) + val online = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + var sends = 0 + val plugin = + plugin( + gateway, + clients, + sender = PackSender { _, _ -> sends++ }, + events = events, + environment = + mapOf( + "GROUNDS_ENVIRONMENT" to "stage", + "CHAT_FONT_PACK_SHA1S" to "1".repeat(40), + ), + appearanceProtocol = { ProtocolVersion.MINECRAFT_1_21_9 }, + platformLookup = ChatPlatformLookup { false }, + appearanceRegistry = registry, + chatStyleBridge = bridge, + ) + + plugin.onInitialize(ProxyInitializeEvent()) + assertEquals(1, bridge.registrations) + val query = requireNotNull(registry.query) + val initialSnapshot = snapshot(initial, packs = listOf(resolvedPack(role = "platform"))) + val client = clients.created.single() + client.emit(readyState(initial, initialSnapshot)) + plugin.onPlayerConfiguration(PlayerConfigurationEvent(online, null)) + assertFalse(query.appearanceOf(online.uniqueId).chatFontReady) + + terminalStatus(events, online) + assertTrue(query.appearanceOf(online.uniqueId).chatFontReady) + assertEquals(emptyList(), bridge.snapshots) + + val sendsBeforeRepeatedConfiguration = sends + plugin.onPlayerConfiguration(PlayerConfigurationEvent(online, null)) + assertEquals(sendsBeforeRepeatedConfiguration, sends) + assertTrue(query.appearanceOf(online.uniqueId).chatFontReady) + + bridge.connected = true + plugin.onServerPostConnect(ServerPostConnectEvent(online, null)) + assertEquals(listOf(true), bridge.snapshots.map { it.second.ready }) + + plugin.onServerPostConnect(ServerPostConnectEvent(online, previousServer())) + assertEquals(1, sends) + assertTrue(query.appearanceOf(online.uniqueId).chatFontReady) + assertEquals(listOf(true, true), bridge.snapshots.map { it.second.ready }) + + client.emit( + readyState( + initial, + snapshot(initial, sequence = 2, packs = listOf(resolvedPack(role = "platform"))), + ) + ) + plugin.onServerPostConnect(ServerPostConnectEvent(online, previousServer())) + assertEquals(2, sends) + assertFalse(query.appearanceOf(online.uniqueId).chatFontReady) + assertEquals(listOf(true, true, false, false), bridge.snapshots.map { it.second.ready }) + terminalStatus(events, online) + assertTrue(query.appearanceOf(online.uniqueId).chatFontReady) + assertEquals( + listOf(true, true, false, false, true), + bridge.snapshots.map { it.second.ready }, + ) + + packStatus(events, online, PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD) + assertFalse(bridge.snapshots.last().second.ready) + terminalStatus(events, online) + plugin.onDisconnect(DisconnectEvent(online, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) + assertEquals( + listOf(false, true, false), + bridge.snapshots.takeLast(3).map { it.second.ready }, + ) + assertTrue( + bridge.snapshots.zipWithNext().all { (first, second) -> + second.second.revision > first.second.revision + } + ) + + plugin.onShutdown(ProxyShutdownEvent()) + assertEquals(1, registry.unregisters) + assertEquals(1, bridge.unregistrations) + assertFalse(query.appearanceOf(online.uniqueId).chatFontReady) + } + + @Test + fun `loaded pack stays false for linked Bedrock and unknown platform`() { + val initial = settings() + val gateway = FakeConfigGateway(ConfigRegistrationResult.ready(), initial) + val clients = FakeClientFactory() + val events = FakeEventRegistry() + val bridge = RecordingPlayerChatStyleBridge() + val online = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + var bedrock: Boolean? = true + val plugin = + plugin( + gateway, + clients, + events = events, + environment = + mapOf( + "GROUNDS_ENVIRONMENT" to "stage", + "CHAT_FONT_PACK_SHA1S" to "1".repeat(40), + ), + appearanceProtocol = { ProtocolVersion.MINECRAFT_1_21_9 }, + platformLookup = ChatPlatformLookup { bedrock }, + chatStyleBridge = bridge, + ) + + plugin.onInitialize(ProxyInitializeEvent()) + clients.created + .single() + .emit( + readyState( + initial, + snapshot(initial, packs = listOf(resolvedPack(role = "platform"))), + ) + ) + plugin.onPlayerConfiguration(PlayerConfigurationEvent(online, null)) + terminalStatus(events, online) + assertFalse(bridge.snapshots.last().second.ready) + + bedrock = null + plugin.onServerPostConnect(ServerPostConnectEvent(online, null)) + assertFalse(bridge.snapshots.last().second.ready) + + bedrock = false + plugin.onServerPostConnect(ServerPostConnectEvent(online, previousServer())) + assertTrue(bridge.snapshots.last().second.ready) + } + + @Test + fun `chat style channel blocks client and server supplied packets`() { + val bridge = RecordingPlayerChatStyleBridge() + val plugin = + plugin( + FakeConfigGateway(ConfigRegistrationResult.ready()), + FakeClientFactory(), + chatStyleBridge = bridge, + ) + val online = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val server = + Proxy.newProxyInstance( + ServerConnection::class.java.classLoader, + arrayOf(ServerConnection::class.java), + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.singleOrNull() + "hashCode" -> 1 + else -> defaultValue(method.returnType) + } + } as ServerConnection + + val clientPacket = PluginMessageEvent(online, server, bridge.identifier, byteArrayOf(1)) + val serverPacket = PluginMessageEvent(server, online, bridge.identifier, byteArrayOf(1)) + plugin.onPluginMessage(clientPacket) + plugin.onPluginMessage(serverPacket) + + assertFalse(clientPacket.result.isAllowed) + assertFalse(serverPacket.result.isAllowed) + } + // Break caught: sending outside the client's configuration state can bypass the configured // prompt screen. @Test @@ -983,6 +1155,8 @@ class GroundsResourcePacksPluginTest { val old = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") { disconnects++ } val replacement = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") { disconnects++ } val sent = mutableListOf() + val registry = RecordingPlayerChatAppearanceRegistry() + val bridge = RecordingPlayerChatStyleBridge() val plugin = plugin( FakeConfigGateway(ConfigRegistrationResult.ready(), initial), @@ -990,8 +1164,18 @@ class GroundsResourcePacksPluginTest { sender = PackSender { player, _ -> sent += player }, events = events, deadline = deadline, + environment = + mapOf( + "GROUNDS_ENVIRONMENT" to "stage", + "CHAT_FONT_PACK_SHA1S" to "1".repeat(40), + ), + appearanceProtocol = { ProtocolVersion.MINECRAFT_1_21_9 }, + platformLookup = ChatPlatformLookup { false }, + appearanceRegistry = registry, + chatStyleBridge = bridge, ) plugin.onInitialize(ProxyInitializeEvent()) + val query = requireNotNull(registry.query) val oldContinuation = RecordingContinuation() requireNotNull(plugin.onPlayerConfiguration(PlayerConfigurationEvent(old, null))) .execute(oldContinuation) @@ -1005,14 +1189,99 @@ class GroundsResourcePacksPluginTest { plugin.onDisconnect(DisconnectEvent(old, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) assertEquals(0, disconnects) assertEquals(0, replacementContinuation.resumes) - clients.created.single().emit(readyState(initial, snapshot(initial))) + clients.created + .single() + .emit( + readyState( + initial, + snapshot(initial, packs = listOf(resolvedPack(role = "platform"))), + ) + ) assertEquals(listOf(replacement), sent) assertTrue(deadline.entries[1].cancelled) deadline.invoke(1) assertEquals(0, replacementContinuation.resumes) + terminalStatus(events, old) + assertFalse(query.appearanceOf(replacement.uniqueId).chatFontReady) assertEquals(0, disconnects) terminalStatus(events, replacement) assertEquals(1, replacementContinuation.resumes) + assertTrue(query.appearanceOf(replacement.uniqueId).chatFontReady) + val snapshotsBeforeOldDisconnect = bridge.snapshots.size + plugin.onDisconnect(DisconnectEvent(old, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) + assertTrue(query.appearanceOf(replacement.uniqueId).chatFontReady) + assertEquals(snapshotsBeforeOldDisconnect, bridge.snapshots.size) + } + + @Test + fun `status admitted before replacement cannot restore old session readiness`() { + val initial = settings() + val clients = FakeClientFactory() + val events = FakeEventRegistry() + val registry = RecordingPlayerChatAppearanceRegistry() + val old = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val replacement = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val eligibilityEntered = CountDownLatch(1) + val releaseEligibility = CountDownLatch(1) + val blockEligibility = AtomicBoolean(false) + val plugin = + plugin( + FakeConfigGateway(ConfigRegistrationResult.ready(), initial), + clients, + events = events, + environment = + mapOf( + "GROUNDS_ENVIRONMENT" to "stage", + "CHAT_FONT_PACK_SHA1S" to "1".repeat(40), + ), + appearanceProtocol = { ProtocolVersion.MINECRAFT_1_21_9 }, + platformLookup = + ChatPlatformLookup { + if (blockEligibility.compareAndSet(true, false)) { + eligibilityEntered.countDown() + assertTrue(releaseEligibility.await(1, TimeUnit.SECONDS)) + } + false + }, + appearanceRegistry = registry, + ) + plugin.onInitialize(ProxyInitializeEvent()) + clients.created + .single() + .emit( + readyState( + initial, + snapshot(initial, packs = listOf(resolvedPack(role = "platform"))), + ) + ) + plugin.onPlayerConfiguration(PlayerConfigurationEvent(old, null)) + blockEligibility.set(true) + val executor = Executors.newFixedThreadPool(2) + try { + val oldStatus = executor.submit { terminalStatus(events, old) } + assertTrue(eligibilityEntered.await(1, TimeUnit.SECONDS)) + val replacementStarted = CountDownLatch(1) + val replacementLogin = + executor.submit { + replacementStarted.countDown() + plugin.onPlayerConfiguration(PlayerConfigurationEvent(replacement, null)) + } + assertTrue(replacementStarted.await(1, TimeUnit.SECONDS)) + releaseEligibility.countDown() + oldStatus.get(1, TimeUnit.SECONDS) + replacementLogin.get(1, TimeUnit.SECONDS) + } finally { + releaseEligibility.countDown() + executor.shutdownNow() + } + + val query = requireNotNull(registry.query) + assertFalse(query.appearanceOf(replacement.uniqueId).chatFontReady) + plugin.onDisconnect(DisconnectEvent(old, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) + terminalStatus(events, old) + assertFalse(query.appearanceOf(replacement.uniqueId).chatFontReady) + terminalStatus(events, replacement) + assertTrue(query.appearanceOf(replacement.uniqueId).chatFontReady) } @Test @@ -1126,13 +1395,16 @@ class GroundsResourcePacksPluginTest { } private fun terminalStatus(events: FakeEventRegistry, player: Player) { + packStatus(events, player, PlayerResourcePackStatusEvent.Status.SUCCESSFUL) + } + + private fun packStatus( + events: FakeEventRegistry, + player: Player, + status: PlayerResourcePackStatusEvent.Status, + ) { (events.registered.single().second as ResourcePackStatusListener).onStatus( - PlayerResourcePackStatusEvent( - player, - resolvedPack().uuid, - PlayerResourcePackStatusEvent.Status.SUCCESSFUL, - null, - ) + PlayerResourcePackStatusEvent(player, resolvedPack().uuid, status, null) ) } @@ -1371,16 +1643,25 @@ class GroundsResourcePacksPluginTest { events: FakeEventRegistry = FakeEventRegistry(), log: FakeResourcePackLog = FakeResourcePackLog(), deadline: ResourcePackSnapshotDeadline = FakeSnapshotDeadline(), + environment: Map = mapOf("GROUNDS_ENVIRONMENT" to "stage"), + appearanceProtocol: (UUID) -> ProtocolVersion? = { null }, + platformLookup: ChatPlatformLookup = ChatPlatformLookup { null }, + appearanceRegistry: PlayerChatAppearanceRegistry = NoopPlayerChatAppearanceRegistry, + chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, ) = GroundsResourcePacksPlugin( dataDirectory = directory, - environment = { mapOf("GROUNDS_ENVIRONMENT" to "stage") }, + environment = { environment }, configGateway = gateway, clientFactory = clients, sender = sender, eventRegistry = events, log = log, snapshotDeadline = deadline, + appearanceProtocol = appearanceProtocol, + platformLookup = platformLookup, + appearanceRegistry = appearanceRegistry, + chatStyleBridge = chatStyleBridge, ) private fun fireSubscribed(plugin: GroundsResourcePacksPlugin, event: Any) { @@ -1395,6 +1676,43 @@ class GroundsResourcePacksPluginTest { } } +internal class RecordingPlayerChatStyleBridge(var connected: Boolean = true) : + PlayerChatStyleBridge { + override val identifier = + com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier.from( + PlayerChatStylePayload.CHANNEL + ) + val snapshots = mutableListOf>() + var registrations = 0 + var unregistrations = 0 + + override fun register() { + registrations++ + } + + override fun unregister() { + unregistrations++ + } + + override fun send(player: Player, snapshot: PlayerChatStyleSnapshot) { + if (connected) snapshots += player.uniqueId to snapshot + } +} + +internal class RecordingPlayerChatAppearanceRegistry : PlayerChatAppearanceRegistry { + var query: PlayerChatAppearanceQuery? = null + var unregisters = 0 + + override fun register(query: PlayerChatAppearanceQuery) { + this.query = query + } + + override fun unregister() { + unregisters++ + query = null + } +} + internal class FakeSnapshotDeadline : ResourcePackSnapshotDeadline { data class Entry(val action: () -> Unit, var cancelled: Boolean = false) diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearanceTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearanceTest.kt new file mode 100644 index 0000000..7d0ce62 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatAppearanceTest.kt @@ -0,0 +1,177 @@ +package gg.grounds.resourcepacks.velocity + +import com.velocitypowered.api.network.ProtocolVersion +import gg.grounds.proxy.api.PlayerChatAppearance +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlayerChatAppearanceTest { + private val playerId = UUID.fromString("12345678-1234-4321-8765-123456789abc") + private val oldPack = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + private val newPack = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + private val oldHash = "1".repeat(40) + private val allowedHash = "2".repeat(40) + + @Test + fun `font readiness requires matching successful load`() { + val tracker = PlayerChatAppearanceTracker(setOf(allowedHash)) + + tracker.offered(playerId, mapOf(newPack to allowedHash)) + assertFalse(tracker.chatFontReady(playerId)) + + tracker.loaded(playerId, newPack) + assertTrue(tracker.chatFontReady(playerId)) + } + + @Test + fun `only offered platform pack fingerprints are tracked`() { + val settings = settings() + val platformPack = resolvedPack(role = "platform", uuid = newPack, sha1 = allowedHash) + val contentPack = resolvedPack(role = "content", uuid = oldPack, sha1 = oldHash) + val snapshot = snapshot(settings, packs = listOf(contentPack, platformPack)) + + val prepared = + requireNotNull( + VelocityPackRequestFactory().prepare(settings, readyState(settings, snapshot)) + ) + + assertEquals(mapOf(newPack to allowedHash), prepared.packHashes) + } + + @Test + fun `old hash and late prior events cannot enable or clear current readiness`() { + val tracker = PlayerChatAppearanceTracker(setOf(allowedHash)) + tracker.offered(playerId, mapOf(oldPack to oldHash)) + tracker.loaded(playerId, oldPack) + assertFalse(tracker.chatFontReady(playerId)) + + tracker.offered(playerId, mapOf(newPack to allowedHash)) + tracker.loaded(playerId, newPack) + tracker.failed(playerId, oldPack) + assertTrue(tracker.chatFontReady(playerId)) + } + + @Test + fun `reused UUID with different hashes is ambiguous and fails closed`() { + val tracker = PlayerChatAppearanceTracker(setOf(allowedHash, oldHash)) + tracker.offered(playerId, mapOf(oldPack to oldHash)) + tracker.offered(playerId, mapOf(oldPack to allowedHash)) + + tracker.loaded(playerId, oldPack) + + assertFalse(tracker.chatFontReady(playerId)) + } + + @Test + fun `disconnect and shutdown clear readiness`() { + val tracker = PlayerChatAppearanceTracker(setOf(allowedHash)) + tracker.offered(playerId, mapOf(newPack to allowedHash)) + tracker.loaded(playerId, newPack) + + tracker.forget(playerId) + assertFalse(tracker.chatFontReady(playerId)) + tracker.offered(playerId, mapOf(newPack to allowedHash)) + tracker.loaded(playerId, newPack) + tracker.clear() + assertFalse(tracker.chatFontReady(playerId)) + } + + @Test + fun `style snapshots stay globally ordered across player resets`() { + val tracker = PlayerChatAppearanceTracker(setOf(allowedHash)) + tracker.offered(playerId, mapOf(newPack to allowedHash)) + tracker.loaded(playerId, newPack) + val ready = tracker.snapshot(playerId) { true } + + tracker.offered(playerId, mapOf(newPack to allowedHash)) + val unavailable = tracker.snapshot(playerId) { true } + + assertTrue(ready.ready) + assertFalse(unavailable.ready) + assertTrue(unavailable.revision > ready.revision) + val disconnect = tracker.disconnectSnapshot(playerId) + assertTrue(disconnect.revision > unavailable.revision) + val nextSession = tracker.snapshot(playerId) { true } + assertTrue(nextSession.revision > disconnect.revision) + tracker.reset(playerId) + assertTrue(tracker.snapshot(playerId) { true }.revision > nextSession.revision) + } + + @Test + fun `linked Bedrock UUID and platform lookup failure both stay text only`() { + val tracker = PlayerChatAppearanceTracker(emptySet()) + val linkedJavaShapedUuid = playerId + val bedrock = + ResourcePackPlayerChatAppearanceQuery( + protocol = { ProtocolVersion.MINECRAFT_1_21_9 }, + platform = ChatPlatformLookup { id -> id == linkedJavaShapedUuid }, + tracker = tracker, + ) + val failedLookup = + ResourcePackPlayerChatAppearanceQuery( + protocol = { ProtocolVersion.MINECRAFT_1_21_9 }, + platform = ChatPlatformLookup { null }, + tracker = tracker, + ) + + assertEquals(PlayerChatAppearance(), bedrock.appearanceOf(playerId)) + assertEquals(PlayerChatAppearance(), failedLookup.appearanceOf(playerId)) + } + + @Test + fun `reflection failure is remembered and remains unknown`() { + var attempts = 0 + var reports = 0 + val lookup = + SafeChatPlatformLookup( + lookup = { + attempts++ + throw ReflectiveOperationException("unavailable") + }, + onFailure = { reports++ }, + ) + + assertNull(lookup.isBedrock(playerId)) + assertNull(lookup.isBedrock(playerId)) + assertEquals(1, attempts) + assertEquals(1, reports) + } + + @Test + fun `native heads require an online supported Java client`() { + val tracker = PlayerChatAppearanceTracker(emptySet()) + val protocols = mutableMapOf(playerId to ProtocolVersion.MINECRAFT_1_21_9) + val query = + ResourcePackPlayerChatAppearanceQuery( + protocol = protocols::get, + platform = ChatPlatformLookup { false }, + tracker = tracker, + ) + + assertEquals(PlayerChatAppearance(javaHeadsSupported = true), query.appearanceOf(playerId)) + protocols[playerId] = ProtocolVersion.MINECRAFT_1_21_7 + assertEquals(PlayerChatAppearance(), query.appearanceOf(playerId)) + protocols.clear() + assertEquals(PlayerChatAppearance(), query.appearanceOf(playerId)) + } + + @Test + fun `allowlist accepts only comma separated lowercase SHA1 values`() { + assertEquals( + setOf(oldHash, allowedHash), + ChatAppearanceEnvironment.from(mapOf("CHAT_FONT_PACK_SHA1S" to "$oldHash,$allowedHash")) + .fontPackSha1s, + ) + assertEquals(emptySet(), ChatAppearanceEnvironment.from(emptyMap()).fontPackSha1s) + val failure = + assertFailsWith { + ChatAppearanceEnvironment.from(mapOf("CHAT_FONT_PACK_SHA1S" to "ABC")) + } + assertFalse(requireNotNull(failure.message).contains("ABC")) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridgeTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridgeTest.kt new file mode 100644 index 0000000..0ead604 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridgeTest.kt @@ -0,0 +1,167 @@ +package gg.grounds.resourcepacks.velocity + +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.messages.ChannelIdentifier +import java.lang.reflect.Proxy +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotSame + +class PlayerChatStyleBridgeTest { + @Test + fun `payload is exact version UUID revision and readiness in twenty six big endian bytes`() { + val playerId = UUID.fromString("12345678-1234-4321-8765-123456789abc") + + val ready = + PlayerChatStylePayload.encode( + playerId, + PlayerChatStyleSnapshot(0x0102030405060708, ready = true), + ) + val unavailable = + PlayerChatStylePayload.encode(playerId, PlayerChatStyleSnapshot(9, ready = false)) + + assertEquals(PlayerChatStylePayload.SIZE, ready.size) + assertEquals(PlayerChatStylePayload.SIZE, unavailable.size) + assertContentEquals( + byteArrayOf( + 1, + 0x12, + 0x34, + 0x56, + 0x78, + 0x12, + 0x34, + 0x43, + 0x21, + 0x87.toByte(), + 0x65, + 0x12, + 0x34, + 0x56, + 0x78, + 0x9a.toByte(), + 0xbc.toByte(), + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 1, + ), + ready, + ) + assertEquals(0, unavailable.last().toInt()) + + val decoded = ByteBuffer.wrap(ready).order(ByteOrder.BIG_ENDIAN) + assertEquals(PlayerChatStylePayload.VERSION, decoded.get()) + assertEquals(playerId.mostSignificantBits, decoded.long) + assertEquals(playerId.leastSignificantBits, decoded.long) + assertEquals(0x0102030405060708, decoded.long) + assertEquals(1, decoded.get().toInt()) + assertFailsWith { + PlayerChatStylePayload.encode(playerId, PlayerChatStyleSnapshot(0, ready = false)) + } + } + + @Test + fun `receiver ordering rejects an older ready snapshot delivered after newer false`() { + val playerId = UUID.fromString("12345678-1234-4321-8765-123456789abc") + val olderReady = + PlayerChatStylePayload.encode(playerId, PlayerChatStyleSnapshot(4, ready = true)) + val newerFalse = + PlayerChatStylePayload.encode(playerId, PlayerChatStyleSnapshot(5, ready = false)) + var lastRevision = 0L + var ready = false + + listOf(newerFalse, olderReady).forEach { payload -> + val decoded = decode(payload) + if (decoded.revision > lastRevision) { + lastRevision = decoded.revision + ready = decoded.ready + } + } + + assertEquals(5, lastRevision) + assertFalse(ready) + } + + @Test + fun `transport skips absent server and encodes a fresh player bound snapshot`() { + val player = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val packets = mutableListOf() + val channels = mutableListOf() + val connections = mutableListOf() + var current: ServerConnection? = null + fun server(index: Int) = + Proxy.newProxyInstance( + ServerConnection::class.java.classLoader, + arrayOf(ServerConnection::class.java), + ) { proxy, method, args -> + when (method.name) { + "sendPluginMessage" -> { + connections += index + channels += args!![0] as ChannelIdentifier + packets += args[1] as ByteArray + true + } + "equals" -> proxy === args?.singleOrNull() + "hashCode" -> 1 + else -> defaultValue(method.returnType) + } + } as ServerConnection + var registrations = 0 + var unregistrations = 0 + val bridge = + VelocityPlayerChatStyleBridge( + registerChannel = { registrations++ }, + unregisterChannel = { unregistrations++ }, + currentServer = { current }, + ) + + bridge.register() + bridge.send(player, PlayerChatStyleSnapshot(1, ready = true)) + assertEquals(emptyList(), packets) + + current = server(1) + bridge.send(player, PlayerChatStyleSnapshot(2, ready = true)) + current = server(2) + bridge.send(player, PlayerChatStyleSnapshot(3, ready = false)) + bridge.unregister() + bridge.send(player, PlayerChatStyleSnapshot(4, ready = true)) + + assertEquals(listOf(1, 2), connections) + assertEquals(listOf(bridge.identifier, bridge.identifier), channels) + assertEquals(2, packets.size) + assertEquals(player.uniqueId, decodePlayer(packets[0])) + assertEquals(2, decode(packets[0]).revision) + assertEquals(3, decode(packets[1]).revision) + assertEquals(1, packets[0].last().toInt()) + assertEquals(0, packets[1].last().toInt()) + assertNotSame(packets[0], packets[1]) + assertEquals(1, registrations) + assertEquals(1, unregistrations) + } + + private fun decodePlayer(payload: ByteArray): UUID { + val buffer = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN) + buffer.get() + return UUID(buffer.long, buffer.long) + } + + private fun decode(payload: ByteArray): PlayerChatStyleSnapshot { + val buffer = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN) + buffer.get() + buffer.long + buffer.long + return PlayerChatStyleSnapshot(buffer.long, buffer.get().toInt() == 1) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinatorTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinatorTest.kt index acf8b00..77e8a0a 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinatorTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackCoordinatorTest.kt @@ -10,6 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -343,6 +344,28 @@ class ResourcePackCoordinatorTest { assertTrue(ownedDuringSend) } + @Test + fun `pack ownership is bound to the current player instance`() { + val settings = settings() + val state = readyState(settings, snapshot(settings)) + val old = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val replacement = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val packId = UUID.fromString("11111111-1111-1111-1111-111111111111") + val coordinator = + ResourcePackCoordinator( + { settings }, + { state }, + PackSender { _, _ -> }, + VelocityPackRequestFactory(), + ) + + coordinator.onLogin(old) + coordinator.onLogin(replacement) + + assertFalse(coordinator.ownsPack(old, packId)) + assertTrue(coordinator.ownsPack(replacement, packId)) + } + // Break caught: provisional ownership can survive a non-Exception send failure and make a // pack that was never delivered look owned by the coordinator. @Test diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackTestFixtures.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackTestFixtures.kt index a21eacc..ed64bf2 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackTestFixtures.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePackTestFixtures.kt @@ -181,13 +181,14 @@ internal fun releaseSnapshot( internal fun resolvedPack( order: Int = 1, + role: String = "global", uuid: UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"), uri: URI = URI("https://assets.example.test/base.zip"), sha1: String = "1".repeat(40), ) = ResolvedPack( order = order, - role = "global", + role = role, id = "pack-$order", uuid = uuid, uri = uri, @@ -229,6 +230,7 @@ internal fun player( null } "identity" -> net.kyori.adventure.identity.Identity.identity(id) + "get" -> java.util.Optional.empty() "equals" -> proxy === args?.singleOrNull() "hashCode" -> id.hashCode() "toString" -> "TestPlayer($id)" diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt new file mode 100644 index 0000000..f52bd9a --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt @@ -0,0 +1,26 @@ +package gg.grounds.resourcepacks.velocity + +import gg.grounds.i18n.Translations +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import net.kyori.adventure.text.Component + +class ResourcePacksMessagesTest { + private val messages = + Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader) + + @Test + fun `unavailable disconnect preserves English and supports German`() { + assertEquals( + Component.text("Resource packs are currently unavailable. Please try again."), + messages.render(ResourcePacksMessage.PACKS_UNAVAILABLE, Locale.ENGLISH), + ) + assertEquals( + Component.text( + "Ressourcenpakete sind derzeit nicht verfügbar. Bitte versuche es erneut." + ), + messages.render(ResourcePacksMessage.PACKS_UNAVAILABLE, Locale.GERMAN), + ) + } +} From a8876f4ee7ee7373b02e8685cacd715b6617ce11 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 15 Oct 2026 00:02:24 +0200 Subject: [PATCH 2/3] refactor(chat): separate message context from status level --- README.md | 4 ++++ .../resourcepacks/velocity/ResourcePacksMessagesTest.kt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cdeb283..0da5cdf 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ closes that client. Local runtime diagnostics expose client status, current and fingerprints, and requested/accepted/downloaded/failed/declined counters. Untrusted diagnostic text is replaced by bounded, allowlisted reason codes. +The required-pack unavailable disconnect reason remains localized, plain text. It is deliberately +not a prefixed system message because the player is disconnected before normal chat presentation +is available. + ## Current scope There is no Minestom adapter and no gamemode or per-server PackSet overlay support yet. diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt index f52bd9a..d20317a 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/ResourcePacksMessagesTest.kt @@ -11,7 +11,7 @@ class ResourcePacksMessagesTest { Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader) @Test - fun `unavailable disconnect preserves English and supports German`() { + fun `unavailable disconnect remains unprefixed in English and German`() { assertEquals( Component.text("Resource packs are currently unavailable. Please try again."), messages.render(ResourcePacksMessage.PACKS_UNAVAILABLE, Locale.ENGLISH), From 937d9a4225178388a5da4c54bb00baf6f8ec8890 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 15 Oct 2026 00:30:13 +0200 Subject: [PATCH 3/3] build(chat): consume published shared chat APIs --- velocity/build.gradle.kts | 6 +++--- velocity/gradle.lockfile | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 29c3efe..7b80388 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -6,8 +6,8 @@ configure { packageName("gg.grounds.generated") } dependencies { compileOnly("gg.grounds:plugin-config-common:1.0.0") - compileOnly("gg.grounds:plugin-proxy-api:2.2.0") - implementation("gg.grounds:library-i18n:0.3.0") + compileOnly("gg.grounds:plugin-proxy-api:2.6.0") + implementation("gg.grounds:library-i18n:0.4.0") compileOnly("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( @@ -19,7 +19,7 @@ dependencies { implementation("gg.grounds:resourcepacks-client:1.0.0") testImplementation("gg.grounds:plugin-config-common:1.0.0") - testImplementation("gg.grounds:plugin-proxy-api:2.2.0") + testImplementation("gg.grounds:plugin-proxy-api:2.6.0") testImplementation("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( diff --git a/velocity/gradle.lockfile b/velocity/gradle.lockfile index 04fbdef..8370876 100644 --- a/velocity/gradle.lockfile +++ b/velocity/gradle.lockfile @@ -23,10 +23,10 @@ com.moandjiezana.toml:toml4j:0.7.2=compileClasspath,kapt,kaptClasspath_kaptKotli com.soywiz.korlibs.korte:korte-jvm:4.0.10=dokkaJavadocGeneratorRuntimeResolver~internal com.velocitypowered:velocity-api:3.5.0-SNAPSHOT=compileClasspath,kapt,kaptClasspath_kaptKotlin,kaptClasspath_kaptTestKotlin,kaptTest,testCompileClasspath,testRuntimeClasspath com.velocitypowered:velocity-brigadier:1.0.0-SNAPSHOT=compileClasspath,kapt,kaptClasspath_kaptKotlin,kaptClasspath_kaptTestKotlin,kaptTest,testCompileClasspath,testRuntimeClasspath -gg.grounds:library-i18n:0.3.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +gg.grounds:library-i18n:0.4.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:plugin-config-common:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:plugin-config-velocity:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath -gg.grounds:plugin-proxy-api:2.2.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +gg.grounds:plugin-proxy-api:2.6.0=compileClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:resourcepacks-client:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath gg.grounds:resourcepacks-contract:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation