From b8f21475b65b0f99e438c049c6343922076df4bb Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 15:38:34 +0100 Subject: [PATCH 1/5] feat(resourcepacks): track loaded HUD capabilities --- .../velocity/GroundsResourcePacksPlugin.kt | 16 ++++++++ .../velocity/PlayerHudAppearance.kt | 41 +++++++++++++++++++ .../velocity/PlayerHudAppearanceTest.kt | 27 ++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt 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 5cf85ca..77d4939 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt @@ -63,6 +63,8 @@ internal constructor( private val chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, private val sidebarStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, private val sidebarProtocol: (java.util.UUID) -> Int? = { appearanceProtocol(it)?.protocol }, + private val hudRegistry: PlayerHudAppearanceRegistry = NoopPlayerHudAppearanceRegistry, + private val appliedHashes: (java.util.UUID) -> Set = { emptySet() }, ) { @Inject constructor( @@ -82,6 +84,8 @@ internal constructor( }, platformLookup = FloodgateChatPlatformLookup.create(logger), appearanceRegistry = ProxyPlayerChatAppearanceRegistry, + hudRegistry = ProxyPlayerHudAppearanceRegistry, + appliedHashes = { id -> proxy.getPlayer(id).orElse(null)?.appliedResourcePacks?.mapNotNull { pack -> pack.hash?.joinToString("") { "%02x".format(it.toInt() and 255) } }?.toSet() ?: emptySet() }, chatStyleBridge = VelocityPlayerChatStyleBridge( registerChannel = { proxy.channelRegistrar.register(it) }, @@ -105,6 +109,7 @@ internal constructor( private val configurationWaiter = ResourcePackConfigurationWaiter() private val appearanceTracker = AtomicReference(null) private val sidebarTracker = AtomicReference(null) + private val hudTracker = AtomicReference(null) private val messages = Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader) private val deadlines = Any() @@ -123,6 +128,7 @@ internal constructor( ResourcePackDeliveryObserver { player, prepared -> appearanceTracker.get()?.offered(player.uniqueId, prepared.packHashes) sidebarTracker.get()?.offered(player.uniqueId, prepared.packHashes) + hudTracker.get()?.offered(player.uniqueId, prepared.packHashes) publishChatStyle(player) log.info( "Resource-pack request sent (playerId=${player.uniqueId}, " + @@ -155,6 +161,10 @@ internal constructor( appearanceRegistry.register( ResourcePackPlayerChatAppearanceQuery(appearanceProtocol, platformLookup, tracker) ) + val hudHashes = HudAppearanceEnvironment.from(deployment) + val hud = PlayerChatAppearanceTracker(hudHashes) + hudTracker.set(hud) + hudRegistry.register(ResourcePackPlayerHudAppearanceQuery(hud, hudHashes, sidebarProtocol, platformLookup, appliedHashes)) chatStyleBridge.register() sidebarStyleBridge.register() val definition = resourcePackSettingsDefinition(bootstrapPackSetChannel(deployment)) @@ -179,6 +189,7 @@ internal constructor( if (packId != null) { tracker.loaded(playerId, packId) sidebarTracker.get()?.loaded(playerId, packId) + hudTracker.get()?.loaded(playerId, packId) } true } @@ -188,6 +199,7 @@ internal constructor( if (packId != null) { tracker.failed(playerId, packId) sidebarTracker.get()?.failed(playerId, packId) + hudTracker.get()?.failed(playerId, packId) } true } @@ -249,6 +261,7 @@ internal constructor( if (initialDeadlines[playerId]?.player !== event.player()) { appearanceTracker.get()?.reset(playerId) sidebarTracker.get()?.reset(playerId) + hudTracker.get()?.reset(playerId) } initialDeadlines.put(playerId, initial)?.handle } @@ -292,6 +305,7 @@ internal constructor( } ?: return disconnected.appearance?.let { snapshot -> chatStyleBridge.send(event.player, snapshot) } disconnected.sidebarAppearance?.let { sidebarStyleBridge.send(event.player, it) } + hudTracker.get()?.forget(event.player.uniqueId) disconnected.initial.handle?.close() coordinator.forget(event.player.uniqueId, disconnected.initial.session) configurationWaiter.forget(event.player.uniqueId, disconnected.initial.completion) @@ -331,6 +345,8 @@ internal constructor( coordinator.clear() appearanceTracker.getAndSet(null)?.clear() sidebarTracker.getAndSet(null)?.clear() + hudTracker.getAndSet(null)?.clear() + hudRegistry.unregister() appearanceRegistry.unregister() chatStyleBridge.unregister() sidebarStyleBridge.unregister() diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt new file mode 100644 index 0000000..affb2e4 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt @@ -0,0 +1,41 @@ +package gg.grounds.resourcepacks.velocity + +import gg.grounds.proxy.api.PlayerHudAppearance +import gg.grounds.proxy.api.PlayerHudAppearanceQuery +import gg.grounds.proxy.api.ProxyServiceRegistry +import java.util.UUID + +internal object HudAppearanceEnvironment { + fun from(environment: Map): Set { + val hashes = environment["HUD_FONT_PACK_SHA1S"].orEmpty().split(',').map(String::trim).filter(String::isNotEmpty) + require(hashes.all { it.matches(Regex("[0-9a-f]{40}")) }) { "HUD_FONT_PACK_SHA1S must contain lowercase SHA-1 hashes" } + return hashes.toSet() + } + fun eligible(protocol: Int?, bedrock: Boolean?): Boolean = protocol == 777 && bedrock == false +} + +internal class ResourcePackPlayerHudAppearanceQuery( + private val tracker: PlayerChatAppearanceTracker, + private val hashes: Set, + private val protocol: (UUID) -> Int?, + private val platform: ChatPlatformLookup, + private val appliedHashes: (UUID) -> Set, +) : PlayerHudAppearanceQuery { + override fun appearanceOf(playerId: UUID): PlayerHudAppearance = PlayerHudAppearance( + HudAppearanceEnvironment.eligible(protocol(playerId), platform.isBedrock(playerId)) && + tracker.chatFontReady(playerId) && appliedHashes(playerId).any { it in hashes } + ) +} + +internal interface PlayerHudAppearanceRegistry { + fun register(query: PlayerHudAppearanceQuery) + fun unregister() +} +internal object ProxyPlayerHudAppearanceRegistry : PlayerHudAppearanceRegistry { + override fun register(query: PlayerHudAppearanceQuery) { ProxyServiceRegistry.register(PlayerHudAppearanceQuery::class.java, query) } + override fun unregister() { ProxyServiceRegistry.unregister(PlayerHudAppearanceQuery::class.java) } +} +internal object NoopPlayerHudAppearanceRegistry : PlayerHudAppearanceRegistry { + override fun register(query: PlayerHudAppearanceQuery) = Unit + override fun unregister() = Unit +} diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt new file mode 100644 index 0000000..e95e6c3 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt @@ -0,0 +1,27 @@ +package gg.grounds.resourcepacks.velocity + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlayerHudAppearanceTest { + @Test fun `only explicit HUD hashes enable a loaded pack`() { + val id = UUID.randomUUID(); val pack = UUID.randomUUID(); val hash = "a".repeat(40) + val tracker = PlayerChatAppearanceTracker(HudAppearanceEnvironment.from(mapOf("HUD_FONT_PACK_SHA1S" to hash))) + tracker.offered(id, mapOf(pack to hash)) + assertFalse(tracker.chatFontReady(id)) + tracker.loaded(id, pack) + assertTrue(tracker.chatFontReady(id)) + assertTrue(HudAppearanceEnvironment.eligible(777, false)) + assertFalse(HudAppearanceEnvironment.eligible(777, true)) + assertFalse(HudAppearanceEnvironment.eligible(776, false)) + tracker.failed(id,pack); assertFalse(tracker.chatFontReady(id)) + tracker.offered(id,mapOf(pack to "b".repeat(40)));tracker.loaded(id,pack) + assertFalse(tracker.chatFontReady(id)) + tracker.forget(id); assertFalse(tracker.chatFontReady(id)) + } + @Test fun `chat hash configuration never enables HUD`() { + assertTrue(HudAppearanceEnvironment.from(mapOf("CHAT_FONT_PACK_SHA1S" to "a".repeat(40))).isEmpty()) + } +} From 5b350a3ec4fb4d9e4b471354a07366a82c9f6997 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 16:19:20 +0100 Subject: [PATCH 2/5] fix(resourcepacks): preserve HUD readiness across reconnects --- velocity/build.gradle.kts | 4 +- .../velocity/GroundsResourcePacksPlugin.kt | 31 ++++++++--- .../velocity/PlayerHudAppearance.kt | 35 +++++++++--- .../GroundsResourcePacksPluginTest.kt | 37 +++++++++++++ .../velocity/PlayerHudAppearanceTest.kt | 54 ++++++++++++++++--- 5 files changed, 137 insertions(+), 24 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index ac37349..10784dc 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -6,7 +6,7 @@ configure { packageName("gg.grounds.generated") } dependencies { compileOnly("gg.grounds:plugin-config-common:1.0.0") - compileOnly("gg.grounds:plugin-proxy-api:2.6.0") + compileOnly("gg.grounds:plugin-proxy-api:feat-portal-announcements-SNAPSHOT") implementation("gg.grounds:library-i18n:0.4.4") compileOnly("gg.grounds:plugin-config-velocity:1.0.0") { attributes { @@ -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.6.0") + testImplementation("gg.grounds:plugin-proxy-api:feat-portal-announcements-SNAPSHOT") testImplementation("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( 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 77d4939..c1a5062 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt @@ -85,7 +85,16 @@ internal constructor( platformLookup = FloodgateChatPlatformLookup.create(logger), appearanceRegistry = ProxyPlayerChatAppearanceRegistry, hudRegistry = ProxyPlayerHudAppearanceRegistry, - appliedHashes = { id -> proxy.getPlayer(id).orElse(null)?.appliedResourcePacks?.mapNotNull { pack -> pack.hash?.joinToString("") { "%02x".format(it.toInt() and 255) } }?.toSet() ?: emptySet() }, + appliedHashes = { id -> + proxy + .getPlayer(id) + .orElse(null) + ?.appliedResourcePacks + ?.mapNotNull { pack -> + pack.hash?.joinToString("") { "%02x".format(it.toInt() and 255) } + } + ?.toSet() ?: emptySet() + }, chatStyleBridge = VelocityPlayerChatStyleBridge( registerChannel = { proxy.channelRegistrar.register(it) }, @@ -140,7 +149,8 @@ internal constructor( ResourcePackDeliveryExpectation { player, prepared -> configurationWaiter.expect(player.uniqueId, prepared.packIds) }, - initialDeliveryCompleted = InitialPackDeliveryCompletion(::completeInitialConfiguration), + initialDeliveryCompleted = + InitialPackDeliveryCompletion(::completeInitialConfiguration), ) private var client: ResourcePackClient? = null @@ -164,7 +174,15 @@ internal constructor( val hudHashes = HudAppearanceEnvironment.from(deployment) val hud = PlayerChatAppearanceTracker(hudHashes) hudTracker.set(hud) - hudRegistry.register(ResourcePackPlayerHudAppearanceQuery(hud, hudHashes, sidebarProtocol, platformLookup, appliedHashes)) + hudRegistry.register( + ResourcePackPlayerHudAppearanceQuery( + hud, + hudHashes, + sidebarProtocol, + platformLookup, + appliedHashes, + ) + ) chatStyleBridge.register() sidebarStyleBridge.register() val definition = resourcePackSettingsDefinition(bootstrapPackSetChannel(deployment)) @@ -297,6 +315,7 @@ internal constructor( initialDeadlines[event.player.uniqueId]?.takeIf { it.player === event.player } ?: return@synchronized null initialDeadlines.remove(event.player.uniqueId) + hudTracker.get()?.forget(event.player.uniqueId) DisconnectedPlayer( initial, appearanceTracker.get()?.disconnectSnapshot(event.player.uniqueId), @@ -305,7 +324,6 @@ internal constructor( } ?: return disconnected.appearance?.let { snapshot -> chatStyleBridge.send(event.player, snapshot) } disconnected.sidebarAppearance?.let { sidebarStyleBridge.send(event.player, it) } - hudTracker.get()?.forget(event.player.uniqueId) disconnected.initial.handle?.close() coordinator.forget(event.player.uniqueId, disconnected.initial.session) configurationWaiter.forget(event.player.uniqueId, disconnected.initial.completion) @@ -502,8 +520,9 @@ internal constructor( } private fun scheduleSnapshotDeadline(session: InitialDeadline) { - val handle = - snapshotDeadline.schedule { expireInitialDelivery(session.player.uniqueId, session) } + val handle = snapshotDeadline.schedule { + expireInitialDelivery(session.player.uniqueId, session) + } synchronized(deadlines) { if ( initialDeadlines[session.player.uniqueId] === session && diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt index affb2e4..2b0f53f 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt @@ -7,10 +7,18 @@ import java.util.UUID internal object HudAppearanceEnvironment { fun from(environment: Map): Set { - val hashes = environment["HUD_FONT_PACK_SHA1S"].orEmpty().split(',').map(String::trim).filter(String::isNotEmpty) - require(hashes.all { it.matches(Regex("[0-9a-f]{40}")) }) { "HUD_FONT_PACK_SHA1S must contain lowercase SHA-1 hashes" } + val hashes = + environment["HUD_FONT_PACK_SHA1S"] + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + require(hashes.all { it.matches(Regex("[0-9a-f]{40}")) }) { + "HUD_FONT_PACK_SHA1S must contain lowercase SHA-1 hashes" + } return hashes.toSet() } + fun eligible(protocol: Int?, bedrock: Boolean?): Boolean = protocol == 777 && bedrock == false } @@ -21,21 +29,32 @@ internal class ResourcePackPlayerHudAppearanceQuery( private val platform: ChatPlatformLookup, private val appliedHashes: (UUID) -> Set, ) : PlayerHudAppearanceQuery { - override fun appearanceOf(playerId: UUID): PlayerHudAppearance = PlayerHudAppearance( - HudAppearanceEnvironment.eligible(protocol(playerId), platform.isBedrock(playerId)) && - tracker.chatFontReady(playerId) && appliedHashes(playerId).any { it in hashes } - ) + override fun appearanceOf(playerId: UUID): PlayerHudAppearance = + PlayerHudAppearance( + HudAppearanceEnvironment.eligible(protocol(playerId), platform.isBedrock(playerId)) && + tracker.chatFontReady(playerId) && + appliedHashes(playerId).any { it in hashes } + ) } internal interface PlayerHudAppearanceRegistry { fun register(query: PlayerHudAppearanceQuery) + fun unregister() } + internal object ProxyPlayerHudAppearanceRegistry : PlayerHudAppearanceRegistry { - override fun register(query: PlayerHudAppearanceQuery) { ProxyServiceRegistry.register(PlayerHudAppearanceQuery::class.java, query) } - override fun unregister() { ProxyServiceRegistry.unregister(PlayerHudAppearanceQuery::class.java) } + override fun register(query: PlayerHudAppearanceQuery) { + ProxyServiceRegistry.register(PlayerHudAppearanceQuery::class.java, query) + } + + override fun unregister() { + ProxyServiceRegistry.unregister(PlayerHudAppearanceQuery::class.java) + } } + internal object NoopPlayerHudAppearanceRegistry : PlayerHudAppearanceRegistry { override fun register(query: PlayerHudAppearanceQuery) = Unit + override fun unregister() = Unit } 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 e6fbbdb..8f3fdbb 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt @@ -1684,6 +1684,39 @@ class GroundsResourcePacksPluginTest { } } + @Test fun `reentrant reconnect during disconnect cannot clear the new HUD session`() { + val initial=settings();val clients=FakeClientFactory();val events=FakeEventRegistry() + val old=player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val replacement=player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + lateinit var instance:GroundsResourcePacksPlugin + var query:gg.grounds.proxy.api.PlayerHudAppearanceQuery?=null + var reconnect=false + val registry=object:PlayerHudAppearanceRegistry { + override fun register(value:gg.grounds.proxy.api.PlayerHudAppearanceQuery){query=value} + override fun unregister() { query=null } + } + val bridge=object:PlayerChatStyleBridge by NoopPlayerChatStyleBridge { + override fun send(player:Player,snapshot:PlayerChatStyleSnapshot) { + if(reconnect && player===old && !snapshot.ready) { + reconnect=false + instance.onPlayerConfiguration(PlayerConfigurationEvent(replacement,null)) + terminalStatus(events,replacement) + } + } + } + instance=plugin(FakeConfigGateway(ConfigRegistrationResult.ready(),initial),clients,events=events, + environment=mapOf("GROUNDS_ENVIRONMENT" to "stage","HUD_FONT_PACK_SHA1S" to "1".repeat(40)), + platformLookup=ChatPlatformLookup{false},sidebarProtocol={777},hudRegistry=registry,appliedHashes={setOf("1".repeat(40))},chatStyleBridge=bridge) + instance.onInitialize(ProxyInitializeEvent()) + clients.created.single().emit(readyState(initial,snapshot(initial,packs=listOf(resolvedPack(role="platform"))))) + instance.onPlayerConfiguration(PlayerConfigurationEvent(old,null));terminalStatus(events,old) + assertTrue(query!!.appearanceOf(old.uniqueId).ready) + reconnect=true + instance.onDisconnect(DisconnectEvent(old,DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) + assertTrue(query!!.appearanceOf(replacement.uniqueId).ready) + instance.onShutdown(ProxyShutdownEvent()) + } + private fun plugin( gateway: ResourcePackConfigGateway, clients: FakeClientFactory, @@ -1700,6 +1733,8 @@ class GroundsResourcePacksPluginTest { chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, sidebarStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, sidebarProtocol: (UUID) -> Int? = { appearanceProtocol(it)?.protocol }, + hudRegistry: PlayerHudAppearanceRegistry = NoopPlayerHudAppearanceRegistry, + appliedHashes: (UUID) -> Set = { emptySet() }, ) = GroundsResourcePacksPlugin( dataDirectory = directory, @@ -1716,6 +1751,8 @@ class GroundsResourcePacksPluginTest { chatStyleBridge = chatStyleBridge, sidebarStyleBridge = sidebarStyleBridge, sidebarProtocol = sidebarProtocol, + hudRegistry = hudRegistry, + appliedHashes = appliedHashes, ) private fun fireSubscribed(plugin: GroundsResourcePacksPlugin, event: Any) { diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt index e95e6c3..de393b8 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt @@ -6,9 +6,15 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PlayerHudAppearanceTest { - @Test fun `only explicit HUD hashes enable a loaded pack`() { - val id = UUID.randomUUID(); val pack = UUID.randomUUID(); val hash = "a".repeat(40) - val tracker = PlayerChatAppearanceTracker(HudAppearanceEnvironment.from(mapOf("HUD_FONT_PACK_SHA1S" to hash))) + @Test + fun `only explicit HUD hashes enable a loaded pack`() { + val id = UUID.randomUUID() + val pack = UUID.randomUUID() + val hash = "a".repeat(40) + val tracker = + PlayerChatAppearanceTracker( + HudAppearanceEnvironment.from(mapOf("HUD_FONT_PACK_SHA1S" to hash)) + ) tracker.offered(id, mapOf(pack to hash)) assertFalse(tracker.chatFontReady(id)) tracker.loaded(id, pack) @@ -16,12 +22,44 @@ class PlayerHudAppearanceTest { assertTrue(HudAppearanceEnvironment.eligible(777, false)) assertFalse(HudAppearanceEnvironment.eligible(777, true)) assertFalse(HudAppearanceEnvironment.eligible(776, false)) - tracker.failed(id,pack); assertFalse(tracker.chatFontReady(id)) - tracker.offered(id,mapOf(pack to "b".repeat(40)));tracker.loaded(id,pack) + tracker.failed(id, pack) assertFalse(tracker.chatFontReady(id)) - tracker.forget(id); assertFalse(tracker.chatFontReady(id)) + tracker.offered(id, mapOf(pack to "b".repeat(40))) + tracker.loaded(id, pack) + assertFalse(tracker.chatFontReady(id)) + tracker.forget(id) + assertFalse(tracker.chatFontReady(id)) + } + + @Test + fun `chat hash configuration never enables HUD`() { + assertTrue( + HudAppearanceEnvironment.from(mapOf("CHAT_FONT_PACK_SHA1S" to "a".repeat(40))).isEmpty() + ) } - @Test fun `chat hash configuration never enables HUD`() { - assertTrue(HudAppearanceEnvironment.from(mapOf("CHAT_FONT_PACK_SHA1S" to "a".repeat(40))).isEmpty()) + + @Test + fun `removal from currently applied packs revokes readiness`() { + val id = UUID.randomUUID() + val pack = UUID.randomUUID() + val hash = "a".repeat(40) + val tracker = PlayerChatAppearanceTracker(setOf(hash)) + tracker.offered(id, mapOf(pack to hash)) + tracker.loaded(id, pack) + var applied = setOf(hash) + val query = + ResourcePackPlayerHudAppearanceQuery( + tracker, + setOf(hash), + { 777 }, + ChatPlatformLookup { false }, + { applied }, + ) + assertTrue(query.appearanceOf(id).ready) + applied = emptySet() + assertFalse(query.appearanceOf(id).ready) + applied = setOf(hash) + tracker.reset(id) + assertFalse(query.appearanceOf(id).ready) } } From ec1e7b52f45aac0d4ba2588e84cd46d0b80e64e3 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 16:42:51 +0100 Subject: [PATCH 3/5] build: pin immutable announcement dependencies --- velocity/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 10784dc..e42a28c 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -6,7 +6,7 @@ configure { packageName("gg.grounds.generated") } dependencies { compileOnly("gg.grounds:plugin-config-common:1.0.0") - compileOnly("gg.grounds:plugin-proxy-api:feat-portal-announcements-SNAPSHOT") + compileOnly("gg.grounds:plugin-proxy-api:2.11.2-announcements-938c2d7") implementation("gg.grounds:library-i18n:0.4.4") compileOnly("gg.grounds:plugin-config-velocity:1.0.0") { attributes { @@ -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:feat-portal-announcements-SNAPSHOT") + testImplementation("gg.grounds:plugin-proxy-api:2.11.2-announcements-938c2d7") testImplementation("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( From 004b2e6c61ca040bbbec12e87a7de9b5d0206825 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 16:48:15 +0100 Subject: [PATCH 4/5] build: update locked announcement API across classpaths --- velocity/gradle.lockfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/velocity/gradle.lockfile b/velocity/gradle.lockfile index cdcdfae..4d84e6f 100644 --- a/velocity/gradle.lockfile +++ b/velocity/gradle.lockfile @@ -26,7 +26,7 @@ com.velocitypowered:velocity-brigadier:1.0.0-SNAPSHOT=compileClasspath,kapt,kapt gg.grounds:library-i18n:0.4.4=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.6.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +gg.grounds:plugin-proxy-api:2.11.2-announcements-938c2d7=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 From f90d8df861e1e3957f2a4a2f0ce6b6fd954cc9f5 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 17:03:12 +0100 Subject: [PATCH 5/5] build: complete announcement repository and formatting checks --- .../velocity/GroundsResourcePacksPlugin.kt | 8 +- .../GroundsResourcePacksPluginTest.kt | 81 +++++++++++++------ 2 files changed, 59 insertions(+), 30 deletions(-) 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 c1a5062..27b4f7f 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt @@ -149,8 +149,7 @@ internal constructor( ResourcePackDeliveryExpectation { player, prepared -> configurationWaiter.expect(player.uniqueId, prepared.packIds) }, - initialDeliveryCompleted = - InitialPackDeliveryCompletion(::completeInitialConfiguration), + initialDeliveryCompleted = InitialPackDeliveryCompletion(::completeInitialConfiguration), ) private var client: ResourcePackClient? = null @@ -520,9 +519,8 @@ internal constructor( } private fun scheduleSnapshotDeadline(session: InitialDeadline) { - val handle = snapshotDeadline.schedule { - expireInitialDelivery(session.player.uniqueId, session) - } + val handle = + snapshotDeadline.schedule { expireInitialDelivery(session.player.uniqueId, session) } synchronized(deadlines) { if ( initialDeadlines[session.player.uniqueId] === session && 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 8f3fdbb..dd351b2 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt @@ -1684,35 +1684,66 @@ class GroundsResourcePacksPluginTest { } } - @Test fun `reentrant reconnect during disconnect cannot clear the new HUD session`() { - val initial=settings();val clients=FakeClientFactory();val events=FakeEventRegistry() - val old=player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - val replacement=player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - lateinit var instance:GroundsResourcePacksPlugin - var query:gg.grounds.proxy.api.PlayerHudAppearanceQuery?=null - var reconnect=false - val registry=object:PlayerHudAppearanceRegistry { - override fun register(value:gg.grounds.proxy.api.PlayerHudAppearanceQuery){query=value} - override fun unregister() { query=null } - } - val bridge=object:PlayerChatStyleBridge by NoopPlayerChatStyleBridge { - override fun send(player:Player,snapshot:PlayerChatStyleSnapshot) { - if(reconnect && player===old && !snapshot.ready) { - reconnect=false - instance.onPlayerConfiguration(PlayerConfigurationEvent(replacement,null)) - terminalStatus(events,replacement) + @Test + fun `reentrant reconnect during disconnect cannot clear the new HUD session`() { + val initial = settings() + val clients = FakeClientFactory() + val events = FakeEventRegistry() + val old = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val replacement = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + lateinit var instance: GroundsResourcePacksPlugin + var query: gg.grounds.proxy.api.PlayerHudAppearanceQuery? = null + var reconnect = false + val registry = + object : PlayerHudAppearanceRegistry { + override fun register(value: gg.grounds.proxy.api.PlayerHudAppearanceQuery) { + query = value + } + + override fun unregister() { + query = null } } - } - instance=plugin(FakeConfigGateway(ConfigRegistrationResult.ready(),initial),clients,events=events, - environment=mapOf("GROUNDS_ENVIRONMENT" to "stage","HUD_FONT_PACK_SHA1S" to "1".repeat(40)), - platformLookup=ChatPlatformLookup{false},sidebarProtocol={777},hudRegistry=registry,appliedHashes={setOf("1".repeat(40))},chatStyleBridge=bridge) + val bridge = + object : PlayerChatStyleBridge by NoopPlayerChatStyleBridge { + override fun send(player: Player, snapshot: PlayerChatStyleSnapshot) { + if (reconnect && player === old && !snapshot.ready) { + reconnect = false + instance.onPlayerConfiguration(PlayerConfigurationEvent(replacement, null)) + terminalStatus(events, replacement) + } + } + } + instance = + plugin( + FakeConfigGateway(ConfigRegistrationResult.ready(), initial), + clients, + events = events, + environment = + mapOf( + "GROUNDS_ENVIRONMENT" to "stage", + "HUD_FONT_PACK_SHA1S" to "1".repeat(40), + ), + platformLookup = ChatPlatformLookup { false }, + sidebarProtocol = { 777 }, + hudRegistry = registry, + appliedHashes = { setOf("1".repeat(40)) }, + chatStyleBridge = bridge, + ) instance.onInitialize(ProxyInitializeEvent()) - clients.created.single().emit(readyState(initial,snapshot(initial,packs=listOf(resolvedPack(role="platform"))))) - instance.onPlayerConfiguration(PlayerConfigurationEvent(old,null));terminalStatus(events,old) + clients.created + .single() + .emit( + readyState( + initial, + snapshot(initial, packs = listOf(resolvedPack(role = "platform"))), + ) + ) + instance.onPlayerConfiguration(PlayerConfigurationEvent(old, null)) + terminalStatus(events, old) assertTrue(query!!.appearanceOf(old.uniqueId).ready) - reconnect=true - instance.onDisconnect(DisconnectEvent(old,DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) + reconnect = true + instance.onDisconnect(DisconnectEvent(old, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) assertTrue(query!!.appearanceOf(replacement.uniqueId).ready) instance.onShutdown(ProxyShutdownEvent()) }