diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index ac37349..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:2.6.0") + 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:2.6.0") + testImplementation("gg.grounds:plugin-proxy-api:2.11.2-announcements-938c2d7") testImplementation("gg.grounds:plugin-config-velocity:1.0.0") { attributes { attribute( 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 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..27b4f7f 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,17 @@ 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 +118,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 +137,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 +170,18 @@ 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 +206,7 @@ internal constructor( if (packId != null) { tracker.loaded(playerId, packId) sidebarTracker.get()?.loaded(playerId, packId) + hudTracker.get()?.loaded(playerId, packId) } true } @@ -188,6 +216,7 @@ internal constructor( if (packId != null) { tracker.failed(playerId, packId) sidebarTracker.get()?.failed(playerId, packId) + hudTracker.get()?.failed(playerId, packId) } true } @@ -249,6 +278,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 } @@ -284,6 +314,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), @@ -331,6 +362,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..2b0f53f --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearance.kt @@ -0,0 +1,60 @@ +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/GroundsResourcePacksPluginTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt index e6fbbdb..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,6 +1684,70 @@ 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 +1764,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 +1782,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 new file mode 100644 index 0000000..de393b8 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerHudAppearanceTest.kt @@ -0,0 +1,65 @@ +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() + ) + } + + @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) + } +}