diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml index 1c66747..7590f4d 100644 --- a/.github/workflows/docker-gradle-build-push.yml +++ b/.github/workflows/docker-gradle-build-push.yml @@ -21,3 +21,5 @@ permissions: jobs: reusable: uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main + secrets: + PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }} diff --git a/common/src/test/kotlin/gg/grounds/permissions/invalidation/ProxyPlayerEditionsTest.kt b/common/src/test/kotlin/gg/grounds/permissions/invalidation/ProxyPlayerEditionsTest.kt index 8a0ee79..1dbfb29 100644 --- a/common/src/test/kotlin/gg/grounds/permissions/invalidation/ProxyPlayerEditionsTest.kt +++ b/common/src/test/kotlin/gg/grounds/permissions/invalidation/ProxyPlayerEditionsTest.kt @@ -6,6 +6,29 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class ProxyPlayerEditionsTest { + @Test + fun `linked editions remain available across simultaneous proxy rosters`() { + val remoteBedrock = UUID.randomUUID() + val java = UUID.randomUUID() + ProxyPlayerEditions(emptyMap()).use { editions -> + editions.receive( + """{"schemaVersion":1,"proxy":"bedrock-remote","players":{"$remoteBedrock":true}}""" + .toByteArray() + ) + editions.receive( + """{"schemaVersion":1,"proxy":"java-local","players":{"$java":false}}""" + .toByteArray() + ) + assertTrue(editions.isBedrock(remoteBedrock)) + assertFalse(editions.isBedrock(java)) + // An unrelated local roster refresh must not drop the remote player's edition. + editions.receive( + """{"schemaVersion":1,"proxy":"java-local","players":{}}""".toByteArray() + ) + assertTrue(editions.isBedrock(remoteBedrock)) + } + } + @Test fun `proxy roster identifies linked accounts and ignores malformed replacement`() { val linked = UUID.randomUUID() 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 1926774..402b940 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/MinestomRankNameTags.kt @@ -12,7 +12,9 @@ import net.minestom.server.color.TeamColor import net.minestom.server.entity.Player import net.minestom.server.event.EventNode import net.minestom.server.event.player.PlayerPacketOutEvent +import net.minestom.server.network.packet.server.play.DisplayScoreboardPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket +import net.minestom.server.network.packet.server.play.ScoreboardObjectivePacket import net.minestom.server.network.packet.server.play.TeamsPacket import net.minestom.server.scoreboard.Team import net.minestom.server.timer.TaskSchedule @@ -25,9 +27,17 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto private val entries = mutableMapOf() private val editions = ProxyPlayerEditions() private val viewerEditions = mutableMapOf() + private val statusLine = RankNameTagStatusLine() private val node = EventNode.all("grounds-rank-nameplates") init { + node.addListener(PlayerPacketOutEvent::class.java) { event -> + if (event.isCancelled) return@addListener + val packet = event.packet + if (packet !is DisplayScoreboardPacket && packet !is ScoreboardObjectivePacket) + return@addListener + if (!statusLine.observe(event.player, packet)) event.isCancelled = true + } node.addListener(PlayerPacketOutEvent::class.java) { event -> val packet = event.packet as? TeamsPacket ?: return@addListener if (!packet.teamName().startsWith("grank") || !editions.isBedrock(event.player.uuid)) @@ -102,15 +112,16 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto return@forEach } val bedrock = editions.isBedrock(player.uuid) - val prefix = RankNameTagPrefix.format(role, bedrock) - val bedrockPrefix = RankNameTagPrefix.format(role, bedrock, bedrockViewer = true) + val prefix = RankNameTagPrefix.format(role, bedrock, includeStatus = false) + val bedrockPrefix = + RankNameTagPrefix.format(role, bedrock, bedrockViewer = true, includeStatus = false) if (existing == null) { val teamName = "grank" + sequence.incrementAndGet().toString(36) bedrockPrefixes[teamName] = prefix to bedrockPrefix val team = MinecraftServer.getTeamManager() .createBuilder(teamName) - .teamColor(TeamColor.WHITE) + .teamColor(TeamColor.GRAY) .allowFriendlyFire() .prefix(prefix) .build() @@ -142,6 +153,7 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto } } } + statusLine.refresh(players.values, editions::isBedrock) } private fun remove(id: UUID, player: Player?) { @@ -155,6 +167,7 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto override fun close() { task.cancel() MinecraftServer.getGlobalEventHandler().removeChild(node) + statusLine.close() editions.close() val players = MinecraftServer.getConnectionManager().onlinePlayers.associateBy { it.uuid } entries.keys.toList().forEach { remove(it, players[it]) } diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefix.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefix.kt index e5fcdd1..32c4016 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefix.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefix.kt @@ -13,7 +13,12 @@ import net.kyori.adventure.text.format.TextColor /** Uses the tab list's badge glyphs, label metrics and role aliases without its column padding. */ internal object RankNameTagPrefix { - fun format(role: RoleMetadata, bedrock: Boolean, bedrockViewer: Boolean = false): Component { + fun format( + role: RoleMetadata, + bedrock: Boolean, + bedrockViewer: Boolean = false, + includeStatus: Boolean = true, + ): Component { val row = Component.text() val colour = role.color?.let(TextColor::fromHexString) ?: TextColor.fromHexString("#F3F0EA")!! @@ -24,7 +29,7 @@ internal object RankNameTagPrefix { ) row.append(gap(bedrockViewer)) } - if (bedrock) { + if (bedrock && includeStatus) { row.append( Component.text( if (bedrockViewer) '\uE500' else TabGlyphs.BEDROCK_ICON, diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt new file mode 100644 index 0000000..41f43ea --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLine.kt @@ -0,0 +1,132 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.minestom.badge.TabGlyphs +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.format.NamedTextColor +import net.minestom.server.entity.Player +import net.minestom.server.network.packet.server.ServerPacket +import net.minestom.server.network.packet.server.play.DisplayScoreboardPacket +import net.minestom.server.network.packet.server.play.ResetScorePacket +import net.minestom.server.network.packet.server.play.ScoreboardObjectivePacket +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 { + private data class Viewer( + val player: Player, + var scores: Map = emptyMap(), + var active: Boolean = false, + ) + + private val objective = "grstatus" + sequence.incrementAndGet().toString(36) + private val viewers = mutableMapOf() + private val gameObjectives = mutableMapOf() + + @Synchronized + fun observe(viewer: Player, packet: ServerPacket): Boolean { + when (packet) { + is DisplayScoreboardPacket -> { + if (packet.position() != 2.toByte()) return true + // Socket events fire when queued packets drain. A game can claim the slot + // after refresh enqueues our display: suppress that now-stale activation. + if (packet.scoreName() == objective && viewer.uuid in gameObjectives) { + viewers[viewer.uuid]?.active = false + return false + } + if (packet.scoreName().isNotEmpty() && packet.scoreName() != objective) + gameObjectives[viewer.uuid] = packet.scoreName() + else gameObjectives.remove(viewer.uuid) + viewers[viewer.uuid]?.active = packet.scoreName() == objective + } + is ScoreboardObjectivePacket -> { + // BelowNameTag.removeViewer deletes its objective without clearing the slot. + if ( + packet.mode() == 1.toByte() && + gameObjectives[viewer.uuid] == packet.objectiveName() + ) { + gameObjectives.remove(viewer.uuid) + } + } + else -> Unit + } + return true + } + + @Synchronized + fun refresh(players: Collection, isBedrock: (UUID) -> Boolean) { + 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 } + players.forEach { player -> + if (subjects.isEmpty()) { + viewers.remove(player.uuid)?.let { remove(it.player) } + return@forEach + } + if (player.uuid in gameObjectives) return@forEach + val state = + viewers.getOrPut(player.uuid) { + player.sendPacket( + ScoreboardObjectivePacket( + objective, + 0, + Component.empty(), + ScoreboardObjectivePacket.Type.INTEGER, + NumberFormat.blank(), + ) + ) + Viewer(player) + } + val scores = if (isBedrock(player.uuid)) bedrockScores else javaScores + (state.scores.keys - scores.keys).forEach { name -> + player.sendPacket(ResetScorePacket(name, objective)) + } + scores.forEach { (name, content) -> + if (state.scores[name] != content) { + player.sendPacket( + UpdateScorePacket(name, objective, 0, null, NumberFormat.fixed(content)) + ) + } + } + state.scores = scores + // Populate scores before displaying, so Java never briefly shows a zero. + if (!state.active) { + state.active = true + player.sendPacket(DisplayScoreboardPacket(2, objective)) + } + } + } + + private fun remove(player: Player) { + player.sendPacket( + ScoreboardObjectivePacket( + objective, + 1, + Component.empty(), + ScoreboardObjectivePacket.Type.INTEGER, + null, + ) + ) + } + + @Synchronized + override fun close() { + viewers.values.forEach { remove(it.player) } + viewers.clear() + gameObjectives.clear() + } + + private companion object { + val sequence = AtomicLong() + val JAVA_ICON = + Component.text(TabGlyphs.BEDROCK_ICON, NamedTextColor.WHITE).font(TabGlyphs.FONT) + val BEDROCK_ICON = + Component.text('\uE500', NamedTextColor.WHITE).font(Key.key("minecraft", "default")) + } +} diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefixTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefixTest.kt index 1fe26c8..4b9d8ae 100644 --- a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefixTest.kt +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagPrefixTest.kt @@ -5,12 +5,30 @@ import gg.grounds.permissions.minestom.badge.TabBadge import gg.grounds.permissions.minestom.badge.TabGlyphs import gg.grounds.permissions.minestom.badge.TabSpaces import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.format.TextColor import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test class RankNameTagPrefixTest { + @Test + fun `overhead prefixes omit status while tab prefixes keep it`() { + val role = RoleMetadata("administrator", "Administrator", color = "#C9826B") + for (bedrockViewer in listOf(false, true)) { + val overhead = + RankNameTagPrefix.format(role, true, bedrockViewer, includeStatus = false) + assertEquals(RankNameTagPrefix.format(role, false, bedrockViewer), overhead) + fun plain(c: Component): String = + (if (c is TextComponent) c.content() else "") + + c.children().joinToString("") { plain(it) } + assertFalse( + plain(overhead).contains(if (bedrockViewer) '\uE500' else TabGlyphs.BEDROCK_ICON) + ) + } + } + @Test fun `bedrock viewers get their full colour atlas badge without Java font glyphs`() { assertEquals( diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt new file mode 100644 index 0000000..0036332 --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/RankNameTagStatusLineTest.kt @@ -0,0 +1,173 @@ +package gg.grounds.permissions.minestom + +import java.net.SocketAddress +import java.util.UUID +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.minestom.server.MinecraftServer +import net.minestom.server.entity.Player +import net.minestom.server.network.packet.server.SendablePacket +import net.minestom.server.network.packet.server.play.DisplayScoreboardPacket +import net.minestom.server.network.packet.server.play.ResetScorePacket +import net.minestom.server.network.packet.server.play.ScoreboardObjectivePacket +import net.minestom.server.network.packet.server.play.UpdateScorePacket +import net.minestom.server.network.player.GameProfile +import net.minestom.server.network.player.PlayerConnection +import net.minestom.server.scoreboard.Sidebar.NumberFormat +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class RankNameTagStatusLineTest { + @Test + fun `bedrock status uses native below-name slot and the viewers own font`() { + val javaConnection = RecordingConnection() + val bedrockConnection = RecordingConnection() + val java = player("Java", javaConnection) + val bedrock = player("Bedrock", bedrockConnection) + val line = RankNameTagStatusLine() + line.refresh(listOf(java, bedrock)) { it == bedrock.uuid } + for ((connection, icon) in + listOf(javaConnection to "\uE004", bedrockConnection to "\uE500")) { + val objective = + connection.packets.filterIsInstance().single() + assertEquals(Component.empty(), objective.objectiveValue()) + assertEquals(NumberFormat.blank(), objective.numberFormat()) + assertEquals( + 2.toByte(), + connection.packets.filterIsInstance().single().position(), + ) + val score = connection.packets.filterIsInstance().single() + assertEquals("Bedrock", score.entityName()) + assertEquals(icon, (score.numberFormat()!!.content() as TextComponent).content()) + } + val count = javaConnection.packets.size + line.refresh(listOf(java, bedrock)) { it == bedrock.uuid } + assertEquals(count, javaConnection.packets.size, "Unchanged statuses must not send packets") + line.close() + assertEquals( + 1.toByte(), + (javaConnection.packets.last() as ScoreboardObjectivePacket).mode(), + ) + } + + @Test + fun `game below-name objectives keep ownership until cleared`() { + val connection = RecordingConnection() + val viewer = player("Viewer", connection) + val line = RankNameTagStatusLine() + line.observe(viewer, DisplayScoreboardPacket(2, "game-health")) + line.refresh(listOf(viewer)) { true } + assertTrue(connection.packets.isEmpty()) + line.observe(viewer, DisplayScoreboardPacket(2, "")) + line.refresh(listOf(viewer)) { true } + assertEquals(1, connection.packets.filterIsInstance().size) + line.close() + } + + @Test + fun `queued status display cannot reclaim the slot after a game activates its objective`() { + val connection = RecordingConnection() + val viewer = player("Viewer", connection) + val other = player("Other", RecordingConnection()) + val line = RankNameTagStatusLine() + line.refresh(listOf(viewer, other)) { true } + val queued = connection.packets.filterIsInstance().single() + line.observe(viewer, DisplayScoreboardPacket(2, "game-health")) + assertFalse(line.observe(viewer, queued)) + connection.packets.clear() + line.refresh(listOf(viewer, other)) { it == other.uuid } + assertTrue( + connection.packets.isEmpty(), + "Queued status display must yield to the game objective", + ) + line.observe(viewer, DisplayScoreboardPacket(2, "")) + line.refresh(listOf(viewer, other)) { it == other.uuid } + assertEquals(1, connection.packets.filterIsInstance().size) + line.close() + } + + @Test + fun `deleting the gameplay objective releases the below-name slot`() { + val connection = RecordingConnection() + val viewer = player("Viewer", connection) + val line = RankNameTagStatusLine() + line.observe(viewer, DisplayScoreboardPacket(2, "game-health")) + line.observe( + viewer, + ScoreboardObjectivePacket( + "unrelated", + 1, + Component.empty(), + ScoreboardObjectivePacket.Type.INTEGER, + null, + ), + ) + line.refresh(listOf(viewer)) { true } + assertTrue(connection.packets.isEmpty()) + line.observe( + viewer, + ScoreboardObjectivePacket( + "game-health", + 1, + Component.empty(), + ScoreboardObjectivePacket.Type.INTEGER, + null, + ), + ) + line.refresh(listOf(viewer)) { true } + assertEquals(1, connection.packets.filterIsInstance().size) + line.close() + } + + @Test + fun `edition changes remove stale scores and empty status rows are removed`() { + val connection = RecordingConnection() + val viewer = player("Viewer", connection) + val other = player("Other", RecordingConnection()) + val bedrockIds = mutableSetOf(viewer.uuid, other.uuid) + val line = RankNameTagStatusLine() + line.refresh(listOf(viewer, other), bedrockIds::contains) + bedrockIds.remove(other.uuid) + line.refresh(listOf(viewer, other), bedrockIds::contains) + assertEquals( + "Other", + connection.packets.filterIsInstance().single().owner(), + ) + // A linked account can change viewer edition without reconnecting. + bedrockIds.remove(viewer.uuid) + bedrockIds.add(other.uuid) + line.refresh(listOf(viewer, other), bedrockIds::contains) + val score = connection.packets.filterIsInstance().last() + assertEquals("Other", score.entityName()) + assertEquals("\uE004", (score.numberFormat()!!.content() as TextComponent).content()) + bedrockIds.clear() + line.refresh(listOf(viewer, other), bedrockIds::contains) + assertEquals(1.toByte(), (connection.packets.last() as ScoreboardObjectivePacket).mode()) + val count = connection.packets.size + line.refresh(listOf(viewer, other), bedrockIds::contains) + assertEquals(count, connection.packets.size) + line.close() + } + + private fun player(name: String, connection: RecordingConnection) = + Player(connection, GameProfile(UUID.randomUUID(), name)) + + private class RecordingConnection : PlayerConnection() { + val packets = mutableListOf() + + override fun sendPacket(packet: SendablePacket) { + packets.add(packet) + } + + override fun getRemoteAddress(): SocketAddress? = null + } + + companion object { + @JvmStatic + @BeforeAll + fun boot() { + MinecraftServer.init() + } + } +}