diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt index a34d18c..b136cad 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/motd/CompiledMotd.kt @@ -48,9 +48,8 @@ class CompiledMotd(document: JsonObject, private val alignments: List) { width += (advances[char] ?: 6) + (if ( - char != " " && - style.decoration(TextDecoration.BOLD) == - TextDecoration.State.TRUE + style.decoration(TextDecoration.BOLD) == + TextDecoration.State.TRUE ) 1 else 0) @@ -60,19 +59,57 @@ class CompiledMotd(document: JsonObject, private val alignments: List) { measure(line, Style.empty()) val remaining = (lineWidth - width).coerceAtLeast(0) val alignment = alignments.getOrNull(index) ?: "left" - val spaces = + val target = when (alignment) { - "center" -> kotlin.math.floor(remaining / 8.0 + .5).toInt() - "right" -> kotlin.math.floor(remaining / 4.0 + .5).toInt() - else -> 0 + "center" -> remaining / 2.0 + "right" -> remaining.toDouble() + else -> 0.0 } + val (regular, bold) = padding(target, remaining) if (index > 0) result = result.append(Component.newline()) - result = result.append(Component.text(" ".repeat(spaces))).append(line) + if (regular > 0) + result = + result.append( + Component.text(" ".repeat(regular)).decoration(TextDecoration.BOLD, false) + ) + if (bold > 0) + result = + result.append( + Component.text(" ".repeat(bold)).decoration(TextDecoration.BOLD, true) + ) + result = result.append(line) } return result } companion object { + /** Same bounded 4/5-pixel native space search as library-motd. */ + private fun padding(target: Double, available: Int): Pair { + var regular = 0 + var bold = 0 + var advance = 0 + var error = kotlin.math.abs(target) + for (b in 0..3) { + val near = (target - b * 5) / 4 + for (r in listOf(kotlin.math.floor(near).toInt(), kotlin.math.ceil(near).toInt())) { + val candidate = r * 4 + b * 5 + if (r < 0 || candidate > available) continue + val distance = kotlin.math.abs(candidate - target) + if ( + distance < error || + (distance == error && + (candidate > advance || (candidate == advance && b < bold))) + ) { + regular = r + bold = b + advance = candidate + error = distance + } + } + } + return regular to bold + } + private val advances: Map = CompiledMotd::class .java diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/NativeMotdAlignmentTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/NativeMotdAlignmentTest.kt new file mode 100644 index 0000000..2e1d744 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/motd/NativeMotdAlignmentTest.kt @@ -0,0 +1,107 @@ +package gg.grounds.proxy.velocity.motd + +import com.google.gson.JsonParser +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.Style +import net.kyori.adventure.text.format.TextDecoration +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class NativeMotdAlignmentTest { + private fun characters(component: Component): List> { + val chars = mutableListOf>() + fun walk(node: Component, inherited: Style) { + val style = node.style().merge(inherited, Style.Merge.Strategy.IF_ABSENT_ON_TARGET) + if (node is TextComponent) + node.content().codePoints().forEach { + chars.add( + String(Character.toChars(it)) to + (style.decoration(TextDecoration.BOLD) == TextDecoration.State.TRUE) + ) + } + node.children().forEach { walk(it, style) } + } + walk(component, Style.empty()) + return chars + } + + @Test + fun `matches the shared native padding and resolved placeholder fixtures`() { + val samples = + JsonParser.parseReader( + javaClass.getResourceAsStream("/server-list/native-padding.json")!!.reader() + ) + .asJsonArray + val advances = + JsonParser.parseReader( + javaClass.getResourceAsStream("/motd/java-1.21.1-advances.json")!!.reader() + ) + .asJsonObject + for (sample in samples) { + val s = sample.asJsonObject + val source = s.get("source").asString + val document = + JsonParser.parseString( + """{"nodes":[{"type":"opaque","format":"minimessage","raw":${s.get("source")}}]}""" + ) + .asJsonObject + val values = + s.getAsJsonObject("values").entrySet().associate { it.key to it.value.asString } + val result = + CompiledMotd(document, s.getAsJsonArray("alignments").map { it.asString }) + .render(values, s.get("width").asInt) + val lines = componentLines(result) + assertEquals(s.getAsJsonArray("expected").size(), lines.size, s.get("id").asString) + for ((index, line) in lines.withIndex()) { + val expected = s.getAsJsonArray("expected")[index].asJsonObject + val chars = characters(line) + val count = expected.get("regular").asInt + expected.get("bold").asInt + val prefix = chars.take(count) + assertTrue(prefix.all { it.first == " " }, source) + assertEquals(expected.get("bold").asInt, prefix.count { it.second }, source) + assertEquals( + expected.get("padding").asInt, + prefix.sumOf { if (it.second) 5 else 4 }, + source, + ) + assertEquals( + expected.get("width").asInt, + chars.drop(count).sumOf { + (advances[it.first]?.asInt ?: 6) + if (it.second) 1 else 0 + }, + source, + ) + assertFalse(chars.joinToString("") { it.first }.contains("{{"), source) + } + } + } + + @Test + fun `uses the closest representable padding for short and full-width lines`() { + val document = + JsonParser.parseString("""{"nodes":[{"type":"text","value":"A"}]}""").asJsonObject + for (alignment in listOf("center", "right")) for (remaining in 0..280) { + val target = if (alignment == "center") remaining / 2.0 else remaining.toDouble() + val candidates = + (0..3) + .flatMap { b -> (0..(remaining / 4)).map { r -> r * 4 + b * 5 } } + .filter { it <= remaining } + val expected = + candidates.minWith( + compareBy { kotlin.math.abs(it - target) }.thenByDescending { it } + ) + val chars = + characters( + CompiledMotd(document, listOf(alignment)).render(emptyMap(), remaining + 6) + ) + assertEquals("A", chars.last().first) + assertFalse(chars.last().second, "padding bold must not affect the MOTD") + assertEquals( + expected, + chars.dropLast(1).sumOf { if (it.second) 5 else 4 }, + "$alignment $remaining", + ) + } + } +} diff --git a/velocity/src/test/resources/server-list/native-padding.json b/velocity/src/test/resources/server-list/native-padding.json new file mode 100644 index 0000000..797136a --- /dev/null +++ b/velocity/src/test/resources/server-list/native-padding.json @@ -0,0 +1,10 @@ +[ + {"id":"modern-user-motd","width":267,"source":"GROUNDS NETWORKStage System | {{region}}","values":{"region":"eu"},"alignments":["center","center"],"expected":[{"width":103,"padding":82,"regular":18,"bold":2},{"width":88,"padding":90,"regular":20,"bold":2}]}, + {"id":"legacy-user-motd","width":271,"source":"GROUNDS NETWORKStage System | {{region}}","values":{"region":"eu"},"alignments":["center","center"],"expected":[{"width":103,"padding":84,"regular":21,"bold":0},{"width":88,"padding":92,"regular":23,"bold":0}]}, + {"id":"modern-right","width":267,"source":"A","values":{},"alignments":["right"],"expected":[{"width":6,"padding":261,"regular":64,"bold":1}]}, + {"id":"legacy-right","width":271,"source":"A","values":{},"alignments":["right"],"expected":[{"width":6,"padding":265,"regular":65,"bold":1}]}, + {"id":"bold-space-measurement","width":40,"source":"A A","values":{},"alignments":["center"],"expected":[{"width":19,"padding":10,"regular":0,"bold":2}]}, + {"id":"short-unrepresentable-center","width":8,"source":"A","values":{},"alignments":["center"],"expected":[{"width":6,"padding":0,"regular":0,"bold":0}]}, + {"id":"short-right","width":13,"source":"A","values":{},"alignments":["right"],"expected":[{"width":6,"padding":5,"regular":0,"bold":1}]}, + {"id":"left-preserves-literal-indentation","width":267,"source":" A","values":{},"alignments":["left"],"expected":[{"width":15,"padding":0,"regular":0,"bold":0}]} +]