From b57036ecccf573bc3ce975cc07f8d892eb41b3d1 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sat, 24 Oct 2026 19:46:45 +0200 Subject: [PATCH] feat(minestom): expose the rank badge for other modules Register a RankBadges service next to Permissions so modules like the lobby sidebar can show the same rank chip the tab list and nameplates use. It resolves the player's highest role and returns the bare chip (Java font badge, or the Bedrock atlas glyph for Bedrock viewers) along with the role's display name and colour for a text fallback. The badge internals stay internal. --- .../minestom/GroundsPermissionsModule.kt | 13 +++- .../minestom/MinestomRankNameTags.kt | 2 + .../permissions/minestom/RankBadges.kt | 53 +++++++++++++ .../permissions/minestom/RankBadgesTest.kt | 78 +++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankBadges.kt create mode 100644 minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankBadgesTest.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 6997084..1e6833f 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt @@ -55,7 +55,7 @@ internal interface MinestomPermissionRuntimePlatform { fun removeEventNode(node: EventNode) - fun startNameTags(permissions: Permissions): AutoCloseable? = null + fun startNameTags(permissions: Permissions): MinestomRankNameTags? = null } class GroundsPermissionsModule @@ -92,7 +92,7 @@ internal constructor( private var prefetchExecutor: ExecutorService? = null private var prefetches: PermissionSnapshotPrefetches? = null private var eventNode: EventNode? = null - private var nameTags: AutoCloseable? = null + private var nameTags: MinestomRankNameTags? = null private val snapshotInvalidationLifecycle = PermissionSnapshotInvalidationLifecycle(runtime = "minestom", logger = logger) @@ -187,7 +187,12 @@ internal constructor( ) ctx.services.register(permissions) - nameTags = runtimePlatform.startNameTags(permissions) + val nameTags = runtimePlatform.startNameTags(permissions) + this.nameTags = nameTags + // 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 } + ) ctx.services.registerPlayerPrefetch(MODULE_ID, prefetches) val node = ctx.eventNode("grounds-permissions") @@ -305,7 +310,7 @@ internal constructor( MinecraftServer.getGlobalEventHandler().removeChild(node) } - override fun startNameTags(permissions: Permissions): AutoCloseable = + override fun startNameTags(permissions: Permissions): MinestomRankNameTags = MinestomRankNameTags(permissions) } } 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 07c7ccf..1926774 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt @@ -79,6 +79,8 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto MinecraftServer.getSchedulerManager() .scheduleTask(::refresh, TaskSchedule.nextTick(), TaskSchedule.seconds(1)) + fun isBedrock(id: UUID): Boolean = editions.isBedrock(id) + @Synchronized private fun refresh() { val players = MinecraftServer.getConnectionManager().onlinePlayers.associateBy { it.uuid } diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankBadges.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankBadges.kt new file mode 100644 index 0000000..b41d37c --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankBadges.kt @@ -0,0 +1,53 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.Permissions +import gg.grounds.permissions.RoleMetadata +import gg.grounds.permissions.minestom.badge.BedrockRankBadges +import gg.grounds.permissions.minestom.badge.TabBadge +import gg.grounds.permissions.minestom.badge.TabRankLabels +import java.util.UUID +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.TextColor + +/** + * The rank badge the tab list and nameplates show, for other modules to place elsewhere. + * + * Registered in the module ServiceRegistry; resolve it with `services.get()`. + */ +interface RankBadges { + /** + * The badge for [playerId]'s highest role as [viewerId] should see it: the Java font chip, or + * the Bedrock atlas glyph for a Bedrock viewer. Null when the player has no role to show. + */ + fun badge(playerId: UUID, viewerId: UUID): RankBadge? +} + +/** + * [component] is the bare chip without trailing spacing; [name] and [color] are the role's display + * name and colour for a text fallback. + */ +data class RankBadge(val component: Component, val name: String, val color: TextColor) + +internal class PermissionRankBadges( + private val permissions: Permissions, + private val isBedrock: (UUID) -> Boolean, +) : RankBadges { + override fun badge(playerId: UUID, viewerId: UUID): RankBadge? { + val role = + permissions + .snapshot(playerId) + ?.roleMetadata + ?.minWithOrNull(compareBy { it.sortOrder }.thenBy { it.key }) + ?.takeIf { it.name.isNotBlank() } ?: return null + val label = TabRankLabels.resolve(role) ?: return null + val colour = + role.color?.let(TextColor::fromHexString) ?: TextColor.fromHexString("#F3F0EA")!! + return RankBadge( + component = + if (isBedrock(viewerId)) BedrockRankBadges.chip(label, colour) + else TabBadge.chip(label, colour), + name = role.name, + color = colour, + ) + } +} diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankBadgesTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankBadgesTest.kt new file mode 100644 index 0000000..b659a1b --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankBadgesTest.kt @@ -0,0 +1,78 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.PermissionSnapshot +import gg.grounds.permissions.RoleMetadata +import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.minestom.badge.BedrockRankBadges +import gg.grounds.permissions.minestom.badge.TabBadge +import java.time.Instant +import java.util.UUID +import net.kyori.adventure.text.format.TextColor +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class RankBadgesTest { + private val player = UUID.randomUUID() + private val javaViewer = UUID.randomUUID() + private val bedrockViewer = UUID.randomUUID() + + private fun badges(vararg roles: RoleMetadata) = + PermissionRankBadges( + SnapshotPermissions(mapOf(player to snapshot(roles.toList()))), + isBedrock = { it == bedrockViewer }, + ) + + @Test + fun `java viewers get the tab badge of the highest role with its name and colour`() { + val colour = TextColor.fromHexString("#C9826B")!! + assertEquals( + RankBadge(TabBadge.chip("ADMIN", colour), "Administrator", colour), + badges( + RoleMetadata("developer", "Developer", color = "#A3B18A", sortOrder = 2), + RoleMetadata("administrator", "Administrator", color = "#C9826B", sortOrder = 1), + ) + .badge(player, javaViewer), + ) + } + + @Test + fun `bedrock viewers get the atlas badge`() { + val colour = TextColor.fromHexString("#C9826B")!! + assertEquals( + RankBadge(BedrockRankBadges.chip("ADMIN", colour), "Administrator", colour), + badges(RoleMetadata("administrator", "Administrator", color = "#C9826B")) + .badge(player, bedrockViewer), + ) + } + + @Test + fun `roles without a colour fall back to the default chip colour`() { + assertEquals( + TextColor.fromHexString("#F3F0EA"), + badges(RoleMetadata("builder", "Builder")).badge(player, javaViewer)?.color, + ) + } + + @Test + fun `players without a role or a displayable name have no badge`() { + assertNull(badges().badge(UUID.randomUUID(), javaViewer)) + assertNull(badges().badge(player, javaViewer)) + assertNull(badges(RoleMetadata("custom", " ")).badge(player, javaViewer)) + } + + private fun snapshot(roles: List): PermissionSnapshot { + val now = Instant.now() + return PermissionSnapshot( + player, + 1, + now.minusSeconds(30), + now.plusSeconds(300), + now.plusSeconds(3600), + emptyList(), + emptyList(), + roles.map { it.key }.toSet(), + roles, + ) + } +}