Skip to content

Commit 74e2e88

Browse files
authored
feat(tab): sort by rank and name with a two-line footer (#95)
1 parent 79c3120 commit 74e2e88

9 files changed

Lines changed: 84 additions & 67 deletions

File tree

‎velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,6 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
253253
roleQuery = { ProxyServiceRegistry.get(PlayerRoleQuery::class.java) },
254254
serverQuery = { ProxyServiceRegistry.get(ServerDisplayQuery::class.java) },
255255
isBedrock = roster::isBedrock,
256-
onlineCount = { networkPlayerCount },
257256
)
258257
tabList = tab
259258
proxy.commandManager.register("proxydebug", ProxyDebugCommand(messages))
@@ -271,8 +270,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
271270
proxy.eventManager.register(this, hops)
272271
proxy.commandManager.register("proxy", ProxyCommand(proxyId, service, hops, messages))
273272

274-
// On a timer as well as on join: the ping and the roster both change with no event to hang
275-
// off, and a footer that shows the ping from the moment you logged in is worse than none.
273+
// Roster entries and remote rank snapshots can change between join events.
276274
proxy.scheduler
277275
.buildTask(
278276
this,

‎velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabList.kt‎

Lines changed: 23 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,13 @@ import gg.grounds.proxy.api.ServerDisplayQuery
99
import net.kyori.adventure.text.Component
1010
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
1111

12-
/**
13-
* The header and footer above and below the player list, and the chips and colour of the names in
14-
* it.
15-
*
16-
* The tab list is the only screen a player can open from anywhere, which makes it the right place
17-
* for the two facts that are true everywhere: which network this is, and which backend you are on.
18-
* It belongs to the proxy because both answers do — a backend server knows its own name and nothing
19-
* about how the network should label it.
20-
*
21-
* ```
22-
* [GROUNDS wordmark]
23-
*
24-
* [ADMIN] Steve
25-
* [USER] Alex
26-
*
27-
* Lobby 2 • 42 players online • 24 ms
28-
* ```
29-
*
30-
* The ping is coloured by how bad it is, using the state colours rather than a number nobody reads:
31-
* green under 100 ms, amber under 200, salmon above. A player who has not been measured yet gets a
32-
* dash instead of a fabricated zero.
33-
*/
12+
/** Network branding, rank-coloured names, and rank/name ordering for each viewer's roster. */
3413
class TabList(
3514
private val proxy: ProxyServer,
3615
private val messages: Translations,
3716
private val roleQuery: () -> PlayerRoleQuery?,
3817
private val serverQuery: () -> ServerDisplayQuery?,
3918
private val isBedrock: (java.util.UUID) -> Boolean = { false },
40-
private val onlineCount: () -> Int? = { null },
4119
) {
4220
private val rankWarmup = RankWarmup()
4321

@@ -47,27 +25,15 @@ class TabList(
4725
colourNames(viewer)
4826
}
4927

50-
/**
51-
* Redraws for everyone.
52-
*
53-
* Called on a timer, because the ping and the roster change without an event to hang off. It
54-
* renders per player rather than once for all of them: the footer is in the player's own
55-
* language and carries their own ping, so there is nothing to share.
56-
*/
28+
/** Refreshes each viewer in their language as the roster and rank snapshots change. */
5729
fun refreshAll() {
5830
proxy.allPlayers.forEach(::refresh)
5931
}
6032

6133
private fun header(viewer: Player): Component = messages.render(ProxyMessage.TAB_HEADER, viewer)
6234

6335
private fun footer(viewer: Player): Component =
64-
messages.render(
65-
ProxyMessage.TAB_FOOTER,
66-
viewer,
67-
"server" to serverLabel(viewer),
68-
"ping" to ping(viewer.ping),
69-
"online" to (onlineCount()?.toString() ?: "—"),
70-
)
36+
messages.render(ProxyMessage.TAB_FOOTER, viewer, "server" to serverLabel(viewer))
7137

7238
private fun serverLabel(viewer: Player): Component {
7339
val raw =
@@ -78,12 +44,7 @@ class TabList(
7844
val label =
7945
kindLabel(kind, viewer)
8046
?: plain(messages.render(ProxyMessage.TAB_SERVER_UNKNOWN, viewer))
81-
val peers =
82-
proxy.allServers
83-
.map { it.serverInfo.name }
84-
.filter { query?.displayOf(it)?.kind == kind }
85-
val number = ServerDisplayIds.numberOf(raw, peers)
86-
return Component.text("$label $number", Palette.TEXT)
47+
return Component.text(label, Palette.TEXT)
8748
}
8849

8950
private fun kindLabel(kind: String?, viewer: Player): String? {
@@ -107,8 +68,25 @@ class TabList(
10768
*/
10869
private fun colourNames(viewer: Player) {
10970
val query = roleQuery()
110-
viewer.tabList.entries.forEach { entry ->
111-
val role = query?.highestRoleOf(entry.profile.id)
71+
val entries = viewer.tabList.entries.toList()
72+
val roles = entries.associate { it.profile.id to query?.highestRoleOf(it.profile.id) }
73+
val orders =
74+
TabOrder.orders(
75+
entries.map { entry ->
76+
TabOrder.Entry(
77+
entry.profile.id,
78+
gg.grounds.proxy.api.PlayerDisplayNames.displayName(
79+
entry.profile.name,
80+
isBedrock(entry.profile.id),
81+
),
82+
roles[entry.profile.id]?.sortOrder,
83+
)
84+
}
85+
)
86+
entries.forEach { entry ->
87+
val order = orders.getValue(entry.profile.id)
88+
if (entry.listOrder != order) entry.setListOrder(order)
89+
val role = roles[entry.profile.id]
11290
val supplied = entry.displayNameComponent.orElse(null)
11391
if (
11492
role == null &&
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package gg.grounds.proxy.velocity.tab
2+
3+
import java.util.Locale
4+
import java.util.UUID
5+
6+
/** A viewer's existing roster, ordered independently of local proxy membership. */
7+
internal object TabOrder {
8+
data class Entry(val id: UUID, val name: String, val sortOrder: Int?)
9+
10+
fun orders(entries: List<Entry>): Map<UUID, Int> =
11+
entries
12+
.sortedWith(
13+
compareBy<Entry> { it.sortOrder?.toLong() ?: Long.MAX_VALUE }
14+
.thenBy { it.name.lowercase(Locale.ROOT) }
15+
.thenBy { it.id }
16+
)
17+
.mapIndexed { index, entry -> entry.id to entries.size - index }
18+
.toMap()
19+
}

‎velocity/src/main/resources/gg/grounds/proxy/messages.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Three leading lines balance the 26px wordmark's ascent against the two trailing lines.
22
# U+E018 is TabSpaces.of(16) on each side.
33
tab.header=\n\n\n<font:grounds:tab><white>\uE018\uE000\uE018</white></font>\n\n
4-
tab.footer=\n<text><bold><server></bold></text><muted> • </muted><text><online> players online</text><muted> • </muted><ping>
4+
tab.footer=\n<text><bold><server></bold></text>\n<muted>grounds.gg</muted>
55
tab.server.lobby=Lobby
66
tab.server.game=Game
77
tab.server.match=Match

‎velocity/src/main/resources/gg/grounds/proxy/messages_de.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
tab.header=\n\n\n<font:grounds:tab><white>\uE018\uE000\uE018</white></font>\n\n
2-
tab.footer=\n<text><bold><server></bold></text><muted> • </muted><text><online> Spieler online</text><muted> • </muted><ping>
2+
tab.footer=\n<text><bold><server></bold></text>\n<muted>grounds.gg</muted>
33
tab.server.lobby=Lobby
44
tab.server.game=Spiel
55
tab.server.match=Match

‎velocity/src/main/resources/gg/grounds/proxy/messages_es.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
tab.header=\n\n\n<font:grounds:tab><white>\uE018\uE000\uE018</white></font>\n\n
2-
tab.footer=\n<text><bold><server></bold></text><muted> • </muted><text><online> jugadores conectados</text><muted> • </muted><ping>
2+
tab.footer=\n<text><bold><server></bold></text>\n<muted>grounds.gg</muted>
33
tab.server.lobby=Lobby
44
tab.server.game=Juego
55
tab.server.match=Partida

‎velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
tab.header=\n\n\n<font:grounds:tab><white>\uE018\uE000\uE018</white></font>\n\n
2-
tab.footer=\n<text><bold><server></bold></text><muted> • </muted><text><online> joueurs en ligne</text><muted> • </muted><ping>
2+
tab.footer=\n<text><bold><server></bold></text>\n<muted>grounds.gg</muted>
33
tab.server.lobby=Lobby
44
tab.server.game=Jeu
55
tab.server.match=Match

‎velocity/src/test/kotlin/gg/grounds/proxy/velocity/tab/TabListTest.kt‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,13 @@ class TabListTest {
6666
}
6767

6868
@Test
69-
fun `the footer carries the friendly server network count and ping`() {
70-
val rendered = footer("Lobby 2", 17)
71-
assertTrue(rendered.contains("Lobby 2"), rendered)
72-
assertTrue(rendered.contains("17 ms"), rendered)
73-
assertTrue(rendered.contains("42 players online"), rendered)
74-
assertFalse(rendered.contains("grounds.gg"), rendered)
75-
assertFalse(rendered.contains("nl-ams1"), rendered)
76-
}
77-
78-
@Test
79-
fun `the footer is one status line separated by dots`() {
80-
val rendered = footer("Lobby 2", 17)
81-
val body = rendered.trim().lines()
82-
assertEquals(1, body.size, rendered)
83-
assertEquals("Lobby 2 • 42 players online • 17 ms", body.single())
69+
fun `the footer contains only the server type and domain in every locale`() {
70+
for (locale in
71+
listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) {
72+
val rendered =
73+
plain(messages.render(ProxyMessage.TAB_FOOTER, locale, "server" to "Lobby"))
74+
assertEquals(listOf("Lobby", "grounds.gg"), rendered.trim().lines())
75+
}
8476
}
8577

8678
/** Hard-coding the year is how a footer ends up saying 2026 in 2029. */
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package gg.grounds.proxy.velocity.tab
2+
3+
import java.util.UUID
4+
import org.junit.jupiter.api.Assertions.assertEquals
5+
import org.junit.jupiter.api.Test
6+
7+
class TabOrderTest {
8+
@Test
9+
fun `rank wins over name then equal priorities use case insensitive alphabet`() {
10+
val names = listOf("Aaron", "zoe", "alice", "Bob", "ALICE", "Unknown")
11+
val priorities = listOf<Int?>(20, 0, 0, 0, 0, null)
12+
val entries =
13+
names.mapIndexed { i, name -> TabOrder.Entry(UUID(0, i.toLong()), name, priorities[i]) }
14+
val order = TabOrder.orders(entries)
15+
assertEquals(
16+
listOf("alice", "ALICE", "Bob", "zoe", "Aaron", "Unknown"),
17+
entries.sortedByDescending { order[it.id] }.map { it.name },
18+
)
19+
}
20+
21+
@Test
22+
fun `extreme priorities do not overflow and an unknown rank sorts last`() {
23+
val entries =
24+
listOf(Int.MAX_VALUE, null, Int.MIN_VALUE).mapIndexed { i, priority ->
25+
TabOrder.Entry(UUID(0, i.toLong()), "Player$i", priority)
26+
}
27+
val order = TabOrder.orders(entries)
28+
assertEquals(listOf(entries[2].id, entries[0].id, entries[1].id), order.keys.toList())
29+
}
30+
}

0 commit comments

Comments
 (0)