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
20 changes: 20 additions & 0 deletions api/src/main/kotlin/gg/grounds/proxy/api/ServerDisplayQuery.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package gg.grounds.proxy.api

/**
* How a backend should be named in player-facing UI.
*
* plugin-agones knows the Agones GameServer name and the `grounds/server-type` label. plugin-proxy
* draws the footer and must not import Agones types, so it asks the registry.
*
* With nothing registered, callers still show [ServerDisplay.id] parsed from the Velocity server
* name and omit the kind, rather than printing the full pod name.
*/
interface ServerDisplayQuery {
fun displayOf(serverName: String): ServerDisplay?
}

/**
* @param kind `grounds/server-type` value, e.g. `lobby`
* @param id last `-` segment of the GameServer name, e.g. `s9fwt`
*/
data class ServerDisplay(val kind: String, val id: String)
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import gg.grounds.proxy.api.PlayerLocaleQuery
import gg.grounds.proxy.api.PlayerRoleQuery
import gg.grounds.proxy.api.ProxyService
import gg.grounds.proxy.api.ProxyServiceRegistry
import gg.grounds.proxy.api.ServerDisplayQuery
import gg.grounds.proxy.velocity.command.MotdCommand
import gg.grounds.proxy.velocity.command.OnlineCommand
import gg.grounds.proxy.velocity.command.RegionCommand
Expand Down Expand Up @@ -183,9 +184,13 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
// Looked up per call, not captured: plugin-permissions may register after this runs, and a
// reference taken now would stay null for the life of the proxy.
val tab =
TabList(proxy, messages, region) {
ProxyServiceRegistry.get(PlayerRoleQuery::class.java)
}
TabList(
proxy,
messages,
roleQuery = { ProxyServiceRegistry.get(PlayerRoleQuery::class.java) },
localeQuery = { ProxyServiceRegistry.get(PlayerLocaleQuery::class.java) },
serverQuery = { ProxyServiceRegistry.get(ServerDisplayQuery::class.java) },
)
tabList = tab

// On a timer as well as on join: the ping and the roster both change with no event to hang
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ import gg.grounds.i18n.MessageKey
enum class ProxyMessage(override val id: String) : MessageKey {
TAB_HEADER("tab.header"),
TAB_FOOTER("tab.footer"),
TAB_SERVER_LOBBY("tab.server.lobby"),
TAB_SERVER_GAME("tab.server.game"),
TAB_SERVER_MATCH("tab.server.match"),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package gg.grounds.proxy.velocity.tab

object ServerDisplayIds {
fun idOf(serverName: String): String {
val id = serverName.substringAfterLast('-')
return if (id.isEmpty()) serverName else id
}
}
37 changes: 37 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package gg.grounds.proxy.velocity.tab

import kotlin.math.max
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import net.kyori.adventure.text.format.TextColor

object TabBadge {
fun chip(label: String, fill: TextColor): Component {
val textWidth = VanillaAdvances.width(label)
val pad = 4
val inner =
max(textWidth + pad, TabGlyphs.LEFT_PX + TabGlyphs.RIGHT_PX + TabGlyphs.MIDDLE_PX)
val middles = inner - TabGlyphs.LEFT_PX - TabGlyphs.RIGHT_PX
val badgeWidth = TabGlyphs.LEFT_PX + middles * TabGlyphs.MIDDLE_PX + TabGlyphs.RIGHT_PX
val padLeft = (badgeWidth - textWidth) / 2
val padRight = badgeWidth - textWidth - padLeft
val gap = TabSpaces.of(-1)
val slices = buildString {
append(TabGlyphs.BADGE_LEFT)
append(gap)
repeat(middles) {
append(TabGlyphs.BADGE_MIDDLE)
append(gap)
}
append(TabGlyphs.BADGE_RIGHT)
append(gap)
}
return Component.text()
.append(Component.text(slices, fill).font(TabGlyphs.FONT))
.append(Component.text(TabSpaces.of(-badgeWidth)).font(TabGlyphs.FONT))
.append(Component.text(TabSpaces.of(padLeft)).font(TabGlyphs.FONT))
.append(Component.text(label, NamedTextColor.WHITE))
.append(Component.text(TabSpaces.of(padRight)).font(TabGlyphs.FONT))
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package gg.grounds.proxy.velocity.tab

import net.kyori.adventure.key.Key

object TabGlyphs {
val FONT: Key = Key.key("grounds", "tab")
const val LOGO = '\uE000'
const val BADGE_LEFT = '\uE001'
const val BADGE_MIDDLE = '\uE002'
const val BADGE_RIGHT = '\uE003'
const val LEFT_PX = 3
const val MIDDLE_PX = 1
const val RIGHT_PX = 3
}
79 changes: 53 additions & 26 deletions velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,29 @@ import com.velocitypowered.api.proxy.Player
import com.velocitypowered.api.proxy.ProxyServer
import gg.grounds.i18n.Palette
import gg.grounds.i18n.Translations
import gg.grounds.proxy.api.PlayerRole
import gg.grounds.proxy.api.PlayerLocaleQuery
import gg.grounds.proxy.api.PlayerRoleQuery
import gg.grounds.proxy.api.ServerDisplayQuery
import java.time.Year
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.TextColor
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer

/**
* The header and footer above and below the player list, and the colour of the names in it.
* The header and footer above and below the player list, and the chips and colour of the names in
* it.
*
* The tab list is the only screen a player can open from anywhere, which makes it the right place
* for the two facts that are true everywhere: which network this is, and where in it you are
* standing. It belongs to the proxy because both answers do — a backend server knows its own name
* and nothing about the region it sits in.
* for the two facts that are true everywhere: which network this is, and which backend you are on.
* It belongs to the proxy because both answers do — a backend server knows its own name and nothing
* about how the network should label it.
*
* ```
* Grounds Network
* [GROUNDS wordmark]
*
* Region nl-ams1 Ping 24 ms
* [DE] [ADMIN] Steve
* [EN] [USER] Alex
*
* Lobby s9fwt Ping 24 ms
* grounds.gg 2026
* ```
*
Expand All @@ -32,8 +37,9 @@ import net.kyori.adventure.text.format.TextColor
class TabList(
private val proxy: ProxyServer,
private val messages: Translations,
private val region: () -> String?,
private val roleQuery: () -> PlayerRoleQuery?,
private val localeQuery: () -> PlayerLocaleQuery?,
private val serverQuery: () -> ServerDisplayQuery?,
) {

/** Redraws everything [viewer] sees. */
Expand All @@ -59,35 +65,56 @@ class TabList(
messages.render(
ProxyMessage.TAB_FOOTER,
viewer,
"region" to (region() ?: UNKNOWN),
"server" to serverLabel(viewer),
"ping" to ping(viewer.ping),
"year" to Year.now().value.toString(),
)

private fun serverLabel(viewer: Player): Component {
val raw =
viewer.currentServer.map { it.serverInfo.name }.orElse(null)
?: return Component.text(UNKNOWN, Palette.TEXT_FAINT)
val queried = serverQuery()?.displayOf(raw)
val id = queried?.id ?: ServerDisplayIds.idOf(raw)
val kind = queried?.kind
val kindLabel = kindLabel(kind, viewer)
val text = if (kindLabel == null) id else "$kindLabel $id"
return Component.text(text, Palette.TEXT)
}

private fun kindLabel(kind: String?, viewer: Player): String? {
val key =
when (kind) {
"lobby" -> ProxyMessage.TAB_SERVER_LOBBY
"game" -> ProxyMessage.TAB_SERVER_GAME
"match" -> ProxyMessage.TAB_SERVER_MATCH
else -> return kind
}
return plain(messages.render(key, viewer))
}

/**
* Paints each name in the colour of its owner's highest role.
* Paints each name with language and rank chips, then the name in the role's colour.
*
* A no-op until something registers a [PlayerRoleQuery] — plugin-permissions holds the snapshot
* these colours come from. Until then every name keeps the backend's own display name, which is
* what players see today.
* Locale comes from [PlayerLocaleQuery] when registered, otherwise the locale the client
* announced. Rank comes from [PlayerRoleQuery]. A missing query or a missing value omits that
* chip rather than drawing a placeholder. Display names are always set so a player with no rank
* still shows a language chip.
*/
private fun colourNames(viewer: Player) {
val query = roleQuery() ?: return
val query = roleQuery()
val localeQ = localeQuery()
viewer.tabList.entries.forEach { entry ->
val role = query.highestRoleOf(entry.profile.id) ?: return@forEach
entry.setDisplayName(displayName(entry.profile.name, role))
val role = query?.highestRoleOf(entry.profile.id)
val locale =
localeQ?.localeOf(entry.profile.id)
?: proxy.getPlayer(entry.profile.id).map { it.effectiveLocale }.orElse(null)
entry.setDisplayName(TabName.format(entry.profile.name, locale, role))
}
}

private fun displayName(name: String, role: PlayerRole): Component {
// A colour the service stores badly should cost that one player their colour, not throw on
// a render that runs every few seconds for everybody.
val colour = role.colour?.let(TextColor::fromHexString) ?: Palette.TEXT
val prefix = role.prefix.orEmpty()
return Component.empty()
.append(Component.text(prefix, colour))
.append(Component.text(name, colour))
}
private fun plain(component: Component): String =
PlainTextComponentSerializer.plainText().serialize(component)

companion object {
private const val UNKNOWN = "—"
Expand Down
30 changes: 30 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabName.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package gg.grounds.proxy.velocity.tab

import gg.grounds.i18n.Palette
import gg.grounds.proxy.api.PlayerRole
import java.util.Locale
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.TextColor

object TabName {
fun format(name: String, locale: Locale?, role: PlayerRole?): Component {
val colour = role?.colour?.let(TextColor::fromHexString) ?: Palette.TEXT
val row = Component.text()
locale
?.language
?.takeIf { it.isNotBlank() }
?.let { language ->
row.append(TabBadge.chip(language.uppercase(Locale.ROOT), Palette.TEXT_FAINT))
row.append(Component.text(TabSpaces.of(2)).font(TabGlyphs.FONT))
}
role
?.name
?.takeIf { it.isNotBlank() }
?.let { rank ->
row.append(TabBadge.chip(rank.uppercase(Locale.ROOT), colour))
row.append(Component.text(TabSpaces.of(2)).font(TabGlyphs.FONT))
}
row.append(Component.text(name, colour))
return row.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package gg.grounds.proxy.velocity.tab

/**
* The tab font's space glyphs: one codepoint per signed power of two, so any pixel offset in
* `-`[MAX_OFFSET]`..`[MAX_OFFSET] is expressed as a short string of existing glyphs.
*
* Starts at `U+E010` so it does not collide with the wordmark (`U+E000`) or the badge slices
* (`U+E001`–`U+E003`). The ladder itself matches library-gui `Spaces`.
*/
object TabSpaces {
const val PUA_START: Int = 0xE010

const val PUA_END: Int = 0xF8FF

private val STEPS = intArrayOf(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024)

const val MAX_OFFSET: Int = 2047

private fun codepoint(index: Int, negative: Boolean): Int =
PUA_START + index * 2 + if (negative) 1 else 0

/**
* The glyph string that moves the text cursor [px] pixels; negative moves left. Empty for `0`.
*
* The steps are powers of two and [px] is bounded, so this is a binary decomposition — each
* step appears at most once and the result is never longer than [STEPS]`.size` characters.
*/
fun of(px: Int): String {
require(px >= -MAX_OFFSET && px <= MAX_OFFSET) {
"offset $px is outside +-$MAX_OFFSET, which the space ladder cannot express"
}
if (px == 0) return ""
val negative = px < 0
var remaining = if (negative) -px else px
val out = StringBuilder()
for (index in STEPS.indices.reversed()) {
if (remaining >= STEPS[index]) {
out.appendCodePoint(codepoint(index, negative))
remaining -= STEPS[index]
}
}
return out.toString()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package gg.grounds.proxy.velocity.tab

object VanillaAdvances {
fun width(text: String): Int = text.sumOf { advance(it) }

private fun advance(ch: Char): Int =
when (ch) {
' ' -> 4
'I',
't' -> 4
'i',
'!' -> 2
'l' -> 3
'f',
'k' -> 5
else -> 6
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
tab.header=\n<accent-deep>Grounds</accent-deep> <text>Network</text>\n
tab.footer=\n<muted>Region</muted> <text><region></text> <muted>Ping</muted> <ping>\n<faint>grounds.gg <year>
tab.header=\n<font:grounds:tab><white>\uE000</white></font>\n
tab.footer=\n<text><server></text> <muted>Ping</muted> <ping>\n<faint>grounds.gg <year>
tab.server.lobby=Lobby
tab.server.game=Game
tab.server.match=Match
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package gg.grounds.proxy.velocity.tab

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class ServerDisplayIdsTest {
@Test
fun `the replica id is the last hyphen segment`() {
assertEquals("s9fwt", ServerDisplayIds.idOf("lobby-nl-ams1-tr9pf-s9fwt"))
}

@Test
fun `a name with no hyphen is used whole`() {
assertEquals("lobby", ServerDisplayIds.idOf("lobby"))
}

@Test
fun `a trailing hyphen does not produce an empty id`() {
assertEquals("lobby-", ServerDisplayIds.idOf("lobby-"))
}
}
Loading