From 26ac5cce0546676decdb4390a5b79a39ed6b0d72 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 29 Oct 2026 20:02:43 +0100 Subject: [PATCH] fix(minestom): combine module icons in the native nameplate row --- .../minestom/GroundsPermissionsModule.kt | 1 + .../GroundsPermissionsModuleProvider.kt | 11 +++++- .../minestom/MinestomRankNameTags.kt | 3 +- .../permissions/minestom/NameplateStatuses.kt | 39 +++++++++++++++++++ .../minestom/RankNameTagStatusLine.kt | 30 +++++++++++--- .../minestom/NameplateStatusesTest.kt | 36 +++++++++++++++++ .../minestom/RankNameTagStatusLineTest.kt | 22 +++++++++++ 7 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 minestom/src/main/kotlin/gg/grounds/permissions/minestom/NameplateStatuses.kt create mode 100644 minestom/src/test/kotlin/gg/grounds/permissions/minestom/NameplateStatusesTest.kt diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt index 1e6833f..bbb0bf4 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt @@ -189,6 +189,7 @@ internal constructor( ctx.services.register(permissions) val nameTags = runtimePlatform.startNameTags(permissions) this.nameTags = nameTags + ctx.services.register(nameTags?.statuses ?: NativeNameplateStatuses()) // The name tags own the only edition roster; without them every viewer counts as Java. ctx.services.register( PermissionRankBadges(permissions) { id -> nameTags?.isBedrock(id) ?: false } diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModuleProvider.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModuleProvider.kt index 6ca4bbe..c9d15be 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModuleProvider.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModuleProvider.kt @@ -13,7 +13,16 @@ class GroundsPermissionsModuleProvider : GroundsModuleProvider { override val version: String = BuildInfo.VERSION override val serverTypes: Set = ServerType.entries.toSet() override val descriptor: ModuleDescriptor = - ModuleDescriptor(id = id, version = version, provides = setOf(serviceKey())) + ModuleDescriptor( + id = id, + version = version, + provides = + setOf( + serviceKey(), + serviceKey(), + serviceKey(), + ), + ) override fun create(): GroundsModule = GroundsPermissionsModule() } diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt index 402b940..1a8150e 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt @@ -27,7 +27,8 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto private val entries = mutableMapOf() private val editions = ProxyPlayerEditions() private val viewerEditions = mutableMapOf() - private val statusLine = RankNameTagStatusLine() + val statuses = NativeNameplateStatuses() + private val statusLine = RankNameTagStatusLine(statuses::status) private val node = EventNode.all("grounds-rank-nameplates") init { diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/NameplateStatuses.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/NameplateStatuses.kt new file mode 100644 index 0000000..7c864a7 --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/NameplateStatuses.kt @@ -0,0 +1,39 @@ +package gg.grounds.permissions.minestom + +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.JoinConfiguration + +/** Supplies viewer-specific icons for the native row below a player's name. */ +fun interface NameplateStatusProvider { + fun status(playerId: UUID, viewerId: UUID): Component +} + +/** Registered by grounds.permissions; other modules contribute icons without creating entities. */ +interface NameplateStatuses { + /** The returned handle removes this provider when its module stops. */ + fun register(provider: NameplateStatusProvider): AutoCloseable +} + +internal class NativeNameplateStatuses : NameplateStatuses { + private class Registration(val provider: NameplateStatusProvider) + + private val registrations = CopyOnWriteArrayList() + + override fun register(provider: NameplateStatusProvider): AutoCloseable { + val registration = Registration(provider) + registrations.add(registration) + return AutoCloseable { registrations.remove(registration) } + } + + fun status(playerId: UUID, viewerId: UUID): Component = + Component.join( + JoinConfiguration.separator(Component.space()), + registrations.mapNotNull { registration -> + runCatching { registration.provider.status(playerId, viewerId) } + .getOrNull() + ?.takeUnless { it == Component.empty() } + }, + ) +} diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt index 41f43ea..e6785aa 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt @@ -5,6 +5,7 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicLong import net.kyori.adventure.key.Key import net.kyori.adventure.text.Component +import net.kyori.adventure.text.JoinConfiguration import net.kyori.adventure.text.format.NamedTextColor import net.minestom.server.entity.Player import net.minestom.server.network.packet.server.ServerPacket @@ -15,7 +16,9 @@ import net.minestom.server.network.packet.server.play.UpdateScorePacket import net.minestom.server.scoreboard.Sidebar.NumberFormat /** Native second nameplate row, without extra entities or movement packets. */ -internal class RankNameTagStatusLine : AutoCloseable { +internal class RankNameTagStatusLine( + private val extraStatus: (UUID, UUID) -> Component = { _, _ -> Component.empty() } +) : AutoCloseable { private data class Viewer( val player: Player, var scores: Map = emptyMap(), @@ -61,11 +64,27 @@ internal class RankNameTagStatusLine : AutoCloseable { val online = players.mapTo(mutableSetOf()) { it.uuid } viewers.keys.retainAll(online) gameObjectives.keys.retainAll(online) - val subjects = players.filter { isBedrock(it.uuid) } - val javaScores = subjects.associate { it.username to JAVA_ICON } - val bedrockScores = subjects.associate { it.username to BEDROCK_ICON } + val bedrockSubjects = + players.filter { isBedrock(it.uuid) }.mapTo(mutableSetOf()) { it.uuid } players.forEach { player -> - if (subjects.isEmpty()) { + val icon = if (isBedrock(player.uuid)) BEDROCK_ICON else JAVA_ICON + val scores = buildMap { + players.forEach { subject -> + val parts = + listOfNotNull( + extraStatus(subject.uuid, player.uuid).takeUnless { + it == Component.empty() + }, + icon.takeIf { subject.uuid in bedrockSubjects }, + ) + if (parts.isNotEmpty()) + put( + subject.username, + Component.join(JoinConfiguration.separator(Component.space()), parts), + ) + } + } + if (scores.isEmpty()) { viewers.remove(player.uuid)?.let { remove(it.player) } return@forEach } @@ -83,7 +102,6 @@ internal class RankNameTagStatusLine : AutoCloseable { ) Viewer(player) } - val scores = if (isBedrock(player.uuid)) bedrockScores else javaScores (state.scores.keys - scores.keys).forEach { name -> player.sendPacket(ResetScorePacket(name, objective)) } diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/NameplateStatusesTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/NameplateStatusesTest.kt new file mode 100644 index 0000000..f9f54b1 --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/NameplateStatusesTest.kt @@ -0,0 +1,36 @@ +package gg.grounds.permissions.minestom + +import java.util.UUID +import net.kyori.adventure.text.Component +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class NameplateStatusesTest { + @Test + fun `providers receive subject and viewer and release their icons on close`() { + val statuses = NativeNameplateStatuses() + val subject = UUID.randomUUID() + val viewer = UUID.randomUUID() + val icon = Component.text('\uE005') + val registration = + statuses.register { playerId, viewerId -> + if (playerId == subject && viewerId == viewer) icon else Component.empty() + } + assertEquals(icon, statuses.status(subject, viewer)) + assertEquals(Component.empty(), statuses.status(viewer, subject)) + registration.close() + registration.close() + assertEquals(Component.empty(), statuses.status(subject, viewer)) + } + + @Test + fun `a failed status provider cannot interrupt other modules or the nameplate refresh`() { + val statuses = NativeNameplateStatuses() + statuses.register { _, _ -> error("unavailable") } + statuses.register { _, _ -> Component.text('\uE005') } + assertEquals( + Component.text('\uE005'), + statuses.status(UUID.randomUUID(), UUID.randomUUID()), + ) + } +} diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt index 0036332..1bec901 100644 --- a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt @@ -19,6 +19,28 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test class RankNameTagStatusLineTest { + @Test + fun `voice status shares the bedrock row and is visible even without bedrock players`() { + val connection = RecordingConnection() + val viewer = player("Viewer", connection) + val other = player("Other", RecordingConnection()) + val line = RankNameTagStatusLine { subject, target -> + if (subject == other.uuid && target == viewer.uuid) Component.text('\uE005') + else Component.empty() + } + line.refresh(listOf(viewer, other)) { false } + var score = connection.packets.filterIsInstance().single() + assertEquals("Other", score.entityName()) + assertEquals("\uE005", plain(score.numberFormat()!!.content()!!)) + line.refresh(listOf(viewer, other)) { it == other.uuid } + score = connection.packets.filterIsInstance().last() + assertEquals("\uE005 \uE004", plain(score.numberFormat()!!.content()!!)) + line.close() + } + + private fun plain(c: Component): String = + (if (c is TextComponent) c.content() else "") + c.children().joinToString("") { plain(it) } + @Test fun `bedrock status uses native below-name slot and the viewers own font`() { val javaConnection = RecordingConnection()