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, + ) + } +}