Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docker-gradle-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ permissions:
jobs:
reusable:
uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main
secrets:
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,9 +27,17 @@ internal class MinestomRankNameTags(private val permissions: Permissions) : Auto
private val entries = mutableMapOf<UUID, Entry>()
private val editions = ProxyPlayerEditions()
private val viewerEditions = mutableMapOf<UUID, Boolean>()
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))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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?) {
Expand All @@ -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]) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")!!
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Component> = emptyMap(),
var active: Boolean = false,
)

private val objective = "grstatus" + sequence.incrementAndGet().toString(36)
private val viewers = mutableMapOf<UUID, Viewer>()
private val gameObjectives = mutableMapOf<UUID, String>()

@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<Player>, 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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading