From bd88ba9ba74ed7530f33ffa7ef4f82861d7bb482 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 15:35:36 +0100 Subject: [PATCH 1/8] feat(api): expose verified HUD appearance --- .../gg/grounds/proxy/api/PlayerHudAppearanceQuery.kt | 10 ++++++++++ .../gg/grounds/proxy/api/PlayerHudAppearanceTest.kt | 8 ++++++++ 2 files changed, 18 insertions(+) create mode 100644 api/src/main/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceQuery.kt create mode 100644 api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceQuery.kt b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceQuery.kt new file mode 100644 index 0000000..1d489e9 --- /dev/null +++ b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceQuery.kt @@ -0,0 +1,10 @@ +package gg.grounds.proxy.api + +import java.util.UUID + +/** Only a verified currently loaded HUD-capable pack may enable bitmap HUD glyphs. */ +fun interface PlayerHudAppearanceQuery { + fun appearanceOf(playerId: UUID): PlayerHudAppearance +} + +data class PlayerHudAppearance(val ready: Boolean = false) diff --git a/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt b/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt new file mode 100644 index 0000000..f196fa1 --- /dev/null +++ b/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt @@ -0,0 +1,8 @@ +package gg.grounds.proxy.api + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertFalse + +class PlayerHudAppearanceTest { + @Test fun `unverified appearance is disabled by default`() { assertFalse(PlayerHudAppearance().ready) } +} From 938c2d7316b507f875ffb6cc38253a763a038ed7 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 16:19:20 +0100 Subject: [PATCH 2/8] feat(proxy): display verified announcements by backend role --- .../proxy/api/PlayerHudAppearanceTest.kt | 7 +- velocity/build.gradle.kts | 1 + .../proxy/velocity/GroundsProxyPlugin.kt | 188 +++++- .../announcements/AnnouncementConfigStore.kt | 129 ++++ .../announcements/AnnouncementController.kt | 145 +++++ .../announcements/AnnouncementLayout.kt | 48 ++ .../announcements/AnnouncementOwnership.kt | 18 + .../announcements/AnnouncementSettings.kt | 125 ++++ .../announcements/AnnouncementSynchronizer.kt | 35 ++ .../AnnouncementConfigStoreTest.kt | 137 +++++ .../AnnouncementControllerTest.kt | 150 +++++ .../announcements/AnnouncementLayoutTest.kt | 79 +++ .../AnnouncementSynchronizerTest.kt | 48 ++ .../announcements/announcements-v1.json | 79 +++ .../resources/announcements/manifest.json | 7 + .../resources/announcements/metrics-v1.json | 557 ++++++++++++++++++ 16 files changed, 1747 insertions(+), 6 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStore.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementController.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementOwnership.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizer.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStoreTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementControllerTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizerTest.kt create mode 100644 velocity/src/test/resources/announcements/announcements-v1.json create mode 100644 velocity/src/test/resources/announcements/manifest.json create mode 100644 velocity/src/test/resources/announcements/metrics-v1.json diff --git a/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt b/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt index f196fa1..572f48d 100644 --- a/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt +++ b/api/src/test/kotlin/gg/grounds/proxy/api/PlayerHudAppearanceTest.kt @@ -1,8 +1,11 @@ package gg.grounds.proxy.api -import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test class PlayerHudAppearanceTest { - @Test fun `unverified appearance is disabled by default`() { assertFalse(PlayerHudAppearance().ready) } + @Test + fun `unverified appearance is disabled by default`() { + assertFalse(PlayerHudAppearance().ready) + } } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 59b6faf..6e239ad 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,6 +14,7 @@ repositories { dependencies { implementation(project(":api")) + implementation("gg.grounds:library-minigame-api-hud:feat-portal-announcements-SNAPSHOT") // service-config answers REST; the MOTD store parses its JSON with gson, the // way plugin-match's and plugin-social's clients do. implementation("com.google.code.gson:gson:2.15.0") diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt index d210a5d..3a8e2c5 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -5,9 +5,12 @@ import com.google.inject.Inject import com.velocitypowered.api.event.PostOrder import com.velocitypowered.api.event.ResultedEvent import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent import com.velocitypowered.api.event.connection.LoginEvent import com.velocitypowered.api.event.connection.PostLoginEvent +import com.velocitypowered.api.event.player.KickedFromServerEvent import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.event.player.ServerPreConnectEvent import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyPingEvent import com.velocitypowered.api.event.proxy.ProxyShutdownEvent @@ -21,12 +24,15 @@ import gg.grounds.i18n.Translations import gg.grounds.presentation.VelocitySystemMessages import gg.grounds.proxy.api.NetworkPlayerCounts import gg.grounds.proxy.api.PlayerDisplayQuery +import gg.grounds.proxy.api.PlayerHudAppearanceQuery +import gg.grounds.proxy.api.PlayerLocaleQuery import gg.grounds.proxy.api.PlayerMessageRenderers import gg.grounds.proxy.api.PlayerRoleQuery import gg.grounds.proxy.api.PlayerSessionTransfer import gg.grounds.proxy.api.ProxyService import gg.grounds.proxy.api.ProxyServiceRegistry import gg.grounds.proxy.api.ServerDisplayQuery +import gg.grounds.proxy.velocity.announcements.* import gg.grounds.proxy.velocity.command.MotdCommand import gg.grounds.proxy.velocity.command.OnlineCommand import gg.grounds.proxy.velocity.command.ProxyCommand @@ -52,8 +58,12 @@ import gg.grounds.proxy.velocity.tab.TabParties import gg.grounds.proxy.velocity.tab.VoicePresences import io.micrometer.core.instrument.MeterRegistry import io.nats.client.Subscription +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path import java.time.Duration import java.time.Instant +import java.util.Locale import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -172,6 +182,14 @@ internal fun resolveConfigServiceTarget( class GroundsProxyPlugin @Inject constructor(private val proxy: ProxyServer, private val logger: Logger) { + @Volatile private var announcementSettings: AnnouncementSettings? = null + private var announcementController: AnnouncementController? = null + private var announcementSync: AnnouncementSynchronizer? = null + private var announcementStore: AnnouncementConfigStore? = null + private var announcementPoll: ScheduledTask? = null + private var announcementTick: ScheduledTask? = null + private val announcementQueued = AtomicBoolean(false) + private val messages = Translations.forBundleWithSystemRenderer( "gg.grounds.proxy.messages", @@ -208,10 +226,9 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { /** The per-server counts of a fresh broadcast that carried them, as [NetworkPlayerCounts]. */ private val broadcastServerCounts: NetworkPlayerCounts? - get() = - cachedPlayerCount?.let { - broadcastServerCounts(it.total, it.servers, it.receivedAtNanos, System.nanoTime()) - } + get() = cachedPlayerCount?.let { + broadcastServerCounts(it.total, it.servers, it.receivedAtNanos, System.nanoTime()) + } private var countSubscription: Subscription? = null private var metrics: ProxyMetrics? = null @@ -305,6 +322,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { startMotd() startMaintenance() + startAnnouncements() subscribeForProxy() @@ -604,11 +622,25 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { */ @Subscribe fun onServerConnected(event: ServerPostConnectEvent) { + event.player.currentServer.orElse(null)?.let { backend -> + announcementController?.completedSwitch( + event.player.uniqueId, + event.player, + backend.serverInfo.name, + backend, + ) + } + announcementController?.refreshAll() tabList?.refresh(event.player) } @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + announcementSync?.close() + announcementPoll?.cancel() + announcementTick?.cancel() + announcementController?.close() + announcementStore?.close() bedrockRoster?.close() ProxyServiceRegistry.unregister(ProxyService::class.java) ProxyServiceRegistry.unregister(PlayerDisplayQuery::class.java) @@ -629,6 +661,154 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { } } + @Subscribe + fun onAnnouncementDisconnect(event: DisconnectEvent) { + announcementController?.forget(event.player.uniqueId, event.player) + } + + @Subscribe(order = PostOrder.LAST) + fun onAnnouncementPreConnect(event: ServerPreConnectEvent) { + if (event.result.isAllowed) { + announcementController?.beginSwitch( + event.player.uniqueId, + event.player, + event, + event.player.currentServer.orElse(null), + ) { + event.result.server.orElse(null)?.serverInfo?.name + } + } + } + + @Subscribe(order = PostOrder.LAST) + fun onAnnouncementConnectionFailure(event: KickedFromServerEvent) { + if (event.kickedDuringServerConnect() && event.result is KickedFromServerEvent.Notify) { + val player = event.player + announcementController?.failedSwitch( + player.uniqueId, + player, + event.server.serverInfo.name, + player.currentServer.orElse(null), + player.isActive, + ) + announcementController?.refreshAll() + } + } + + private fun startAnnouncements() { + val ownership = AnnouncementOwnership.parse(System.getenv("GROUNDS_ANNOUNCEMENTS_OWNER")) + if (ownership == null) + logger.error("Invalid GROUNDS_ANNOUNCEMENTS_OWNER; announcements disabled") + if (ownership != AnnouncementOwnership.PROXY) return + val environment = System.getenv("GROUNDS_ENVIRONMENT") + if (environment !in setOf("stage", "prod")) { + logger.error("Announcements require an explicit stage/prod environment") + return + } + val target = resolveConfigServiceTarget() ?: return + val store = + AnnouncementConfigStore( + URI(if (target.contains("://")) target else "http://$target"), + environment!!, + token = { + val path = + Path.of( + System.getenv("GROUNDS_TOKEN_FILE") ?: "/var/run/secrets/grounds/token" + ) + if (Files.exists(path)) Files.readString(path).trim() else null + }, + ) + announcementStore = store + val controller = + AnnouncementController( + viewers = { + proxy.allPlayers.map { player -> + object : AnnouncementViewer { + override val playerId + get() = player.uniqueId + + override val session: Any + get() = player + + override val connected + get() = player.isActive + + override val serverType + get() = + player.currentServer.orElse(null)?.serverInfo?.name?.let { + ProxyServiceRegistry.get(ServerDisplayQuery::class.java) + ?.displayOf(it) + ?.kind + } + + override val locale + get() = + ProxyServiceRegistry.get(PlayerLocaleQuery::class.java) + ?.localeOf(player.uniqueId) + ?: player.effectiveLocale + ?: Locale.ENGLISH + + override val hudReady + get() = + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.ready == true + + override fun show(bar: net.kyori.adventure.bossbar.BossBar) { + player.showBossBar(bar) + } + + override fun hide(bar: net.kyori.adventure.bossbar.BossBar) { + player.hideBossBar(bar) + } + } + } + }, + settings = { announcementSettings }, + ) + announcementController = controller + val sync = + AnnouncementSynchronizer( + store::read, + { + announcementSettings = it + controller.refreshAll() + }, + { + logger.warn( + "Announcement refresh failed; retaining last valid snapshot ({})", + it.javaClass.simpleName, + ) + }, + ) + announcementSync = sync + announcementPoll = + proxy.scheduler + .buildTask(this, Runnable { sync.refresh() }) + .repeat(Duration.ofSeconds(15)) + .schedule() + announcementTick = + proxy.scheduler + .buildTask(this, Runnable { controller.refreshAll() }) + .repeat(Duration.ofSeconds(1)) + .schedule() + crossProxySubscriptions += + natsHandler.subscribe("config.velocity.$environment.changed") { + if (announcementQueued.compareAndSet(false, true)) { + proxy.scheduler + .buildTask( + this, + Runnable { + announcementQueued.set(false) + sync.refresh() + }, + ) + .delay(Duration.ofMillis(250)) + .schedule() + } + } + } + /** * Bring up the Prometheus endpoint, if this proxy was asked for one. * diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStore.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStore.kt new file mode 100644 index 0000000..9a54c05 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStore.kt @@ -0,0 +1,129 @@ +package gg.grounds.proxy.velocity.announcements + +import com.google.gson.JsonParser +import java.io.ByteArrayOutputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.ByteBuffer +import java.time.Duration +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.Flow +import java.util.concurrent.TimeUnit + +sealed interface AnnouncementSnapshot { + data object Unchanged : AnnouncementSnapshot + + data class Changed(val version: Long, val settings: AnnouncementSettings?) : + AnnouncementSnapshot +} + +/** Read-only authority. Neither missing documents nor outages ever write defaults. */ +class AnnouncementConfigStore( + baseUri: URI, + environment: String, + private val token: () -> String?, + private val http: HttpClient = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(), +) : AutoCloseable { + private val uri: URI + + init { + require(environment in setOf("stage", "prod")) + require(baseUri.scheme in setOf("http", "https") && baseUri.userInfo == null) + uri = + baseUri.resolve( + "/v1/config/apps/velocity/envs/$environment/namespaces/announcements/snapshot" + ) + } + + fun read(knownVersion: Long?): AnnouncementSnapshot { + val builder = + HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(5)) + .header("Accept", "application/json") + token() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { builder.header("Authorization", "Bearer $it") } + knownVersion?.let { builder.header("If-None-Match", "\"$it\"") } + val pending = + http.sendAsync(builder.GET().build(), HttpResponse.BodyHandler { BoundedBody() }) + val response = + try { + pending.get(5, TimeUnit.SECONDS) + } catch (failure: Exception) { + pending.cancel(true) + throw failure + } + if (response.statusCode() == 304) { + require(knownVersion != null) + return AnnouncementSnapshot.Unchanged + } + require(response.statusCode() == 200) { + "Announcement snapshot HTTP ${response.statusCode()}" + } + val root = JsonParser.parseString(response.body().toString(Charsets.UTF_8)).asJsonObject + val revision = root["version"].asString.toLong() + require(revision >= 0 && (knownVersion == null || revision >= knownVersion)) + val matching = + root["documents"].asJsonArray.filter { + val d = it.asJsonObject + d["namespace"].asString == "announcements" && d["configKey"].asString == "active" + } + require(matching.size <= 1) + val settings = + if (matching.isEmpty()) null + else { + val content = matching.single().asJsonObject.get("contentJson") + require( + content != null && content.isJsonPrimitive && content.asJsonPrimitive.isString + ) { + "Matching announcement document is missing string contentJson" + } + AnnouncementSettings.parse(content.asString) + } + return AnnouncementSnapshot.Changed(revision, settings) + } + + override fun close() { + http.close() + } + + private class BoundedBody : HttpResponse.BodySubscriber { + private val result = CompletableFuture() + private val bytes = ByteArrayOutputStream() + private lateinit var subscription: Flow.Subscription + + override fun getBody(): CompletionStage = result + + override fun onSubscribe(value: Flow.Subscription) { + subscription = value + subscription.request(1) + } + + override fun onNext(items: List) { + for (item in items) { + if (bytes.size().toLong() + item.remaining() > 262144) { + subscription.cancel() + result.completeExceptionally(IllegalArgumentException("Snapshot exceeds limit")) + return + } + val chunk = ByteArray(item.remaining()) + item.get(chunk) + bytes.write(chunk) + } + subscription.request(1) + } + + override fun onError(error: Throwable) { + result.completeExceptionally(error) + } + + override fun onComplete() { + result.complete(bytes.toByteArray()) + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementController.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementController.kt new file mode 100644 index 0000000..42ba2d7 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementController.kt @@ -0,0 +1,145 @@ +package gg.grounds.proxy.velocity.announcements + +import gg.grounds.hud.HudPills +import java.util.Locale +import java.util.UUID +import net.kyori.adventure.bossbar.BossBar + +/** A connection-scoped view, so tests exercise transitions without a Minecraft transport. */ +interface AnnouncementViewer { + val playerId: UUID + val session: Any + val serverType: String? + val locale: Locale + val hudReady: Boolean + val connected: Boolean + + fun show(bar: BossBar) + + fun hide(bar: BossBar) +} + +class AnnouncementController( + private val viewers: () -> Collection, + private val settings: () -> AnnouncementSettings?, + private val clock: () -> Long = System::currentTimeMillis, +) : AutoCloseable { + private data class Active(val viewer: AnnouncementViewer, val bar: BossBar) + + private val active = mutableMapOf() + + private data class Pending( + val session: Any, + val attempt: Any, + val source: Any?, + val target: () -> String?, + ) + + private val pending = mutableMapOf>() + private var closed = false + + @Synchronized + fun beginSwitch(id: UUID, session: Any, attempt: Any, source: Any?, target: () -> String?) { + if (closed || target() == null) return + val attempts = pending.getOrPut(id) { mutableListOf() } + attempts.removeAll { it.session !== session || it.target() == null } + if (attempts.none { it.attempt === attempt }) + attempts += Pending(session, attempt, source, target) + remove(id) + } + + @Synchronized + fun completedSwitch(id: UUID, session: Any, target: String, backend: Any?) { + val attempts = pending[id] ?: return + attempts.removeAll { + it.session === session && it.target() == target && backend !== it.source + } + if (attempts.isEmpty()) pending.remove(id) + } + + @Synchronized + fun failedSwitch(id: UUID, session: Any, target: String, backend: Any?, connected: Boolean) { + val attempts = pending[id] ?: return + val matching = attempts.filter { it.session === session && it.target() == target } + // A failure event has no attempt ID. Ambiguous overlapping attempts stay fail-closed. + if ( + matching.size == 1 && + backend != null && + backend === matching.single().source && + connected + ) + attempts.remove(matching.single()) + if (attempts.isEmpty()) pending.remove(id) + } + + @Synchronized + fun refreshAll() { + if (closed) return + val current = viewers() + val sessions = current.associate { it.playerId to it.session } + active.values + .toList() + .filter { sessions[it.viewer.playerId] !== it.viewer.session } + .forEach { remove(it.viewer.playerId) } + pending.keys.toList().forEach { id -> + pending[id]?.removeAll { sessions[id] !== it.session } + if (pending[id].isNullOrEmpty()) pending.remove(id) + } + current.forEach(::refresh) + } + + @Synchronized + fun refresh(viewer: AnnouncementViewer) { + if (closed) return + pending[viewer.playerId]?.let { attempts -> + attempts.removeAll { it.session !== viewer.session || it.target() == null } + if (attempts.isEmpty()) pending.remove(viewer.playerId) + else { + remove(viewer.playerId) + return + } + } + val config = settings() + val role = viewer.serverType + val pills = + if (viewer.connected && viewer.hudReady && role != null && config != null) + AnnouncementLayout.groupAt( + AnnouncementLayout.groups(config, role, viewer.locale), + clock(), + ) + else null + if (pills.isNullOrEmpty()) { + remove(viewer.playerId) + return + } + val old = active[viewer.playerId] + if (old != null && old.viewer.session !== viewer.session) remove(viewer.playerId) + val title = HudPills.render(pills) + val existing = active[viewer.playerId] + if (existing != null) { + if (existing.bar.name() != title) existing.bar.name(title) + return + } + val bar = BossBar.bossBar(title, 0f, BossBar.Color.PINK, BossBar.Overlay.PROGRESS) + viewer.show(bar) + active[viewer.playerId] = Active(viewer, bar) + } + + @Synchronized + fun forget(playerId: UUID, session: Any) { + pending[playerId]?.removeAll { it.session === session } + if (pending[playerId].isNullOrEmpty()) pending.remove(playerId) + if (active[playerId]?.viewer?.session === session) remove(playerId) + } + + private fun remove(id: UUID) { + active.remove(id)?.let { it.viewer.hide(it.bar) } + } + + @Synchronized + override fun close() { + closed = true + pending.clear() + active.keys.toList().forEach(::remove) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt new file mode 100644 index 0000000..e25d575 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt @@ -0,0 +1,48 @@ +package gg.grounds.proxy.velocity.announcements + +import gg.grounds.hud.HudMetrics +import gg.grounds.hud.HudPill +import gg.grounds.hud.HudPills +import gg.grounds.hud.HudRun +import java.util.Locale + +object AnnouncementLayout { + fun pill(entry: Announcement, locale: Locale): HudPill = + HudPill( + buildList { + add(HudRun(entry.caption(locale), 0xF3F0EA)) + entry.address?.let { add(HudRun(it, 0xE7B84B, true)) } + } + ) + + fun groups( + settings: AnnouncementSettings, + serverType: String, + locale: Locale, + ): List> { + val groups = mutableListOf>() + var current = mutableListOf() + var width = 0 + for (entry in + settings.announcements.filter { it.enabled && serverType in it.serverTypes }) { + val pill = pill(entry, locale) + val next = HudPills.width(pill) + if ( + current.isNotEmpty() && + (current.size >= 2 || width + 8 + next > HudMetrics.layout("maxWidth")) + ) { + groups += current + current = mutableListOf() + width = 0 + } + width += (if (current.isEmpty()) 0 else 8) + next + current += pill + } + if (current.isNotEmpty()) groups += current + return groups + } + + fun groupAt(groups: List>, epochMillis: Long): List? = + if (groups.isEmpty()) null + else groups[Math.floorMod(Math.floorDiv(epochMillis, 10000), groups.size.toLong()).toInt()] +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementOwnership.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementOwnership.kt new file mode 100644 index 0000000..986e8e2 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementOwnership.kt @@ -0,0 +1,18 @@ +package gg.grounds.proxy.velocity.announcements + +enum class AnnouncementOwnership { + LEGACY, + DISABLED, + PROXY; + + companion object { + fun parse(value: String?): AnnouncementOwnership? = + when (value) { + null, + "legacy" -> LEGACY + "disabled" -> DISABLED + "proxy" -> PROXY + else -> null + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt new file mode 100644 index 0000000..383cb1d --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt @@ -0,0 +1,125 @@ +package gg.grounds.proxy.velocity.announcements + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import gg.grounds.hud.HudMetrics +import java.util.Locale + +data class Announcement( + val id: String, + val name: String, + val enabled: Boolean, + val serverTypes: List, + val text: Map, + val address: String?, +) { + fun caption(locale: Locale): String = + text[locale.language.lowercase(Locale.ROOT)] ?: text.getValue("en") +} + +data class AnnouncementSettings(val announcements: List) { + companion object { + fun parse(json: String): AnnouncementSettings { + require(json.toByteArray(Charsets.UTF_8).size <= 262144) { + "Announcement document too large" + } + try { + val root = JsonParser.parseString(json).asJsonObject + root.fields(setOf("schemaVersion", "announcements")) + require( + root["schemaVersion"].isJsonPrimitive && + root["schemaVersion"].asJsonPrimitive.isNumber && + root["schemaVersion"].asBigDecimal.compareTo(java.math.BigDecimal.ONE) == 0 + ) + val entries = root["announcements"].asJsonArray + require(entries.size() <= 32) + val announcements = entries.map { node -> + val e = node.asJsonObject + e.fields(setOf("id", "name", "enabled", "serverTypes", "text", "address")) + val id = e["id"].string().lowercase(Locale.ROOT) + require( + id.matches( + Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + ) + ) + val name = e["name"].string() + require(name.isNotBlank() && name.length <= 80) + require(e["enabled"].asJsonPrimitive.isBoolean) + val enabled = e["enabled"].asBoolean + val roles = + e["serverTypes"].asJsonArray.map { + it.string().also { role -> + require(role.matches(Regex("[a-z0-9][a-z0-9._-]{0,63}"))) + } + } + require( + roles.size <= 32 && + roles.distinct().size == roles.size && + (!enabled || roles.isNotEmpty()) + ) + val texts = + e["text"].asJsonObject.entrySet().associate { (lang, value) -> + require(lang in setOf("en", "de", "fr", "es")) + lang to value.string().also { validateText(it, 256) } + } + require("en" in texts) + val address = + e["address"] + .takeUnless { it.isJsonNull } + ?.string() + ?.also { validateText(it, 128) } + Announcement(id, name, enabled, roles, texts, address).also { entry -> + for (lang in texts.keys) require( + gg.grounds.hud.HudPills.width( + AnnouncementLayout.pill(entry, Locale.forLanguageTag(lang)) + ) <= HudMetrics.layout("maxWidth") + ) { + "Announcement exceeds HUD width" + } + } + } + require(announcements.map { it.id }.distinct().size == announcements.size) + val canonical = + com.google.gson + .GsonBuilder() + .serializeNulls() + .disableHtmlEscaping() + .create() + .toJson(mapOf("schemaVersion" to 1, "announcements" to announcements)) + require(canonical.toByteArray(Charsets.UTF_8).size <= 65536) { + "Announcement document too large" + } + return AnnouncementSettings(announcements) + } catch (error: IllegalArgumentException) { + throw error + } catch (error: Exception) { + throw IllegalArgumentException("Invalid announcement document", error) + } + } + + private fun validateText(value: String, max: Int) { + require(value.isNotBlank() && value.length <= max) + value.codePoints().forEach { cp -> + require( + cp != 0xA7 && + !Character.isISOControl(cp) && + Character.getType(cp) !in + setOf(Character.FORMAT.toInt(), Character.PRIVATE_USE.toInt()) && + HudMetrics.advance(cp) != null + ) { + "Unsupported HUD text" + } + } + } + + private fun JsonObject.fields(keys: Set) { + require(keySet() == keys) { "Unexpected or missing announcement fields" } + } + + private fun JsonElement.string(): String { + require(isJsonPrimitive && asJsonPrimitive.isString) + return asString + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizer.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizer.kt new file mode 100644 index 0000000..19253ee --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizer.kt @@ -0,0 +1,35 @@ +package gg.grounds.proxy.velocity.announcements + +import java.util.concurrent.atomic.AtomicBoolean + +class AnnouncementSynchronizer( + private val read: (Long?) -> AnnouncementSnapshot, + private val apply: (AnnouncementSettings?) -> Unit, + private val failure: (Throwable) -> Unit, +) : AutoCloseable { + private val reading = AtomicBoolean(false) + @Volatile private var closed = false + @Volatile private var revision: Long? = null + + fun refresh() { + if (closed || !reading.compareAndSet(false, true)) return + try { + val snapshot = read(revision) + synchronized(this) { + if (!closed && snapshot is AnnouncementSnapshot.Changed) { + apply(snapshot.settings) + revision = snapshot.version + } + } + } catch (error: Exception) { + if (!closed) failure(error) + } finally { + reading.set(false) + } + } + + @Synchronized + override fun close() { + closed = true + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStoreTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStoreTest.kt new file mode 100644 index 0000000..4e63447 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementConfigStoreTest.kt @@ -0,0 +1,137 @@ +package gg.grounds.proxy.velocity.announcements + +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.net.URI +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AnnouncementConfigStoreTest { + @Test + fun `reads authoritative snapshots with rotated token and conditional revision`() { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + var expected = "first" + var requests = 0 + server.createContext( + "/v1/config/apps/velocity/envs/stage/namespaces/announcements/snapshot" + ) { exchange -> + assertEquals("Bearer $expected", exchange.requestHeaders.getFirst("Authorization")) + requests++ + if (requests == 1) { + val body = + """{"version":7,"documents":[{"namespace":"announcements","configKey":"active","contentJson":"{\"schemaVersion\":1,\"announcements\":[]}","version":7}]}""" + .toByteArray() + exchange.responseHeaders.add("ETag", "\"7\"") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } else { + assertEquals("\"7\"", exchange.requestHeaders.getFirst("If-None-Match")) + exchange.sendResponseHeaders(304, -1) + exchange.close() + } + } + server.start() + try { + AnnouncementConfigStore( + URI("http://127.0.0.1:${server.address.port}"), + "stage", + { expected }, + ) + .use { store -> + val first = store.read(null) as AnnouncementSnapshot.Changed + assertEquals(7, first.version) + assertTrue(first.settings!!.announcements.isEmpty()) + expected = "second" + assertEquals(AnnouncementSnapshot.Unchanged, store.read(7)) + } + } finally { + server.stop(0) + } + } + + @Test + fun `malformed matching content retains the previous revision and display`() { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + var calls = 0 + val revisions = mutableListOf() + server.createContext("/") { exchange -> + revisions += exchange.requestHeaders.getFirst("If-None-Match") + calls++ + val doc = + com.google.gson.JsonObject().apply { + addProperty("namespace", "announcements") + addProperty("configKey", "active") + if (calls != 2) + addProperty("contentJson", """{"schemaVersion":1,"announcements":[]}""") + } + val root = + com.google.gson.JsonObject().apply { + addProperty("version", calls) + add("documents", com.google.gson.JsonArray().apply { add(doc) }) + } + val body = root.toString().toByteArray() + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + server.start() + try { + AnnouncementConfigStore( + URI("http://127.0.0.1:${server.address.port}"), + "stage", + { null }, + ) + .use { store -> + val applied = mutableListOf() + val sync = AnnouncementSynchronizer(store::read, { applied += it }, {}) + sync.refresh() + sync.refresh() + sync.refresh() + assertEquals(2, applied.size) + assertEquals( + listOf( + null, + 34.toChar().toString() + "1" + 34.toChar(), + 34.toChar().toString() + "1" + 34.toChar(), + ), + revisions, + ) + } + } finally { + server.stop(0) + } + } + + @Test + fun `foreign app documents cannot block clearing announcements`() { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/v1/config/apps/velocity/envs/stage/snapshot") { exchange -> + val body = + """{"version":8,"documents":[{"namespace":"motd","configKey":"active","contentJson":"${"x".repeat(270000)}"}]}""" + .toByteArray() + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + server.createContext( + "/v1/config/apps/velocity/envs/stage/namespaces/announcements/snapshot" + ) { exchange -> + val body = """{"version":8,"documents":[]}""".toByteArray() + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + server.start() + try { + AnnouncementConfigStore( + URI("http://127.0.0.1:${server.address.port}"), + "stage", + { null }, + ) + .use { store -> + val next = store.read(7) as AnnouncementSnapshot.Changed + assertNull(next.settings) + assertEquals(8L, next.version) + } + } finally { + server.stop(0) + } + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementControllerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementControllerTest.kt new file mode 100644 index 0000000..6bdd2f9 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementControllerTest.kt @@ -0,0 +1,150 @@ +package gg.grounds.proxy.velocity.announcements + +import java.util.Locale +import java.util.UUID +import net.kyori.adventure.bossbar.BossBar +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AnnouncementControllerTest { + private class Viewer : AnnouncementViewer { + override val playerId = UUID.randomUUID() + override val session = Any() + override var serverType: String? = "lobby" + override var locale = Locale.GERMAN + override var hudReady = true + override var connected = true + val visible = mutableSetOf() + var shown = 0 + + override fun show(bar: BossBar) { + visible += bar + shown++ + } + + override fun hide(bar: BossBar) { + visible -= bar + } + } + + private fun settings(): AnnouncementSettings = + AnnouncementSettings.parse( + """{"schemaVersion":1,"announcements":[{"id":"7c354d9c-6b16-4f06-9f15-000000000001","name":"test","enabled":true,"serverTypes":["lobby"],"text":{"en":"abc"},"address":null}]}""" + ) + + @Test + fun `lobby game lobby switch filters immediately and keeps only one bar`() { + val viewer = Viewer() + val controller = AnnouncementController({ listOf(viewer) }, { settings() }) + controller.refreshAll() + assertEquals(1, viewer.visible.size) + controller.refreshAll() + assertEquals(1, viewer.shown) + viewer.serverType = "game" + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + viewer.serverType = "lobby" + controller.refreshAll() + assertEquals(1, viewer.visible.size) + viewer.hudReady = false + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + viewer.hudReady = true + controller.close() + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + } + + @Test + fun `two proxies only touch local players and disconnected sessions never revive`() { + val a = Viewer() + val b = Viewer() + val ca = AnnouncementController({ listOf(a) }, { settings() }) + val cb = AnnouncementController({ listOf(b) }, { settings() }) + ca.refreshAll() + cb.refreshAll() + assertEquals(1, a.visible.size) + assertEquals(1, b.visible.size) + a.connected = false + ca.refreshAll() + assertTrue(a.visible.isEmpty()) + assertEquals(1, b.visible.size) + ca.close() + cb.close() + } + + @Test + fun `empty and missing snapshots remove bars and unknown backend stays empty`() { + val a = Viewer() + var config: AnnouncementSettings? = settings() + val controller = AnnouncementController({ listOf(a) }, { config }) + controller.refreshAll() + config = AnnouncementSettings.parse("""{"schemaVersion":1,"announcements":[]}""") + controller.refreshAll() + assertTrue(a.visible.isEmpty()) + config = settings() + a.serverType = null + controller.refreshAll() + assertTrue(a.visible.isEmpty()) + config = null + controller.refreshAll() + assertTrue(a.visible.isEmpty()) + controller.close() + } + + @Test + fun `refresh stays suspended until the matching backend transition finishes`() { + val viewer = Viewer() + val source = Any() + val attempt = Any() + val controller = AnnouncementController({ listOf(viewer) }, { settings() }) + controller.refreshAll() + assertEquals(1, viewer.visible.size) + controller.beginSwitch(viewer.playerId, viewer.session, attempt, source) { "game" } + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + controller.completedSwitch(viewer.playerId, viewer.session, "wrong-target", Any()) + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + viewer.serverType = "game" + controller.completedSwitch(viewer.playerId, viewer.session, "game", Any()) + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + viewer.serverType = "lobby" + controller.refreshAll() + assertEquals(1, viewer.visible.size) + controller.beginSwitch(viewer.playerId, viewer.session, Any(), source) { "game" } + controller.failedSwitch(viewer.playerId, viewer.session, "game", Any(), true) + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + controller.failedSwitch(viewer.playerId, viewer.session, "game", source, true) + controller.refreshAll() + assertEquals(1, viewer.visible.size) + controller.close() + } + + @Test + fun `later denial does not leave a lobby session suspended`() { + val viewer = Viewer() + val controller = AnnouncementController({ listOf(viewer) }, { settings() }) + var target: String? = "game" + controller.beginSwitch(viewer.playerId, viewer.session, Any(), Any()) { target } + target = null + controller.refreshAll() + assertEquals(1, viewer.visible.size) + controller.close() + } + + @Test + fun `denying a later attempt cannot release an earlier pending switch`() { + val viewer = Viewer() + val controller = AnnouncementController({ listOf(viewer) }, { settings() }) + controller.beginSwitch(viewer.playerId, viewer.session, Any(), Any()) { "game" } + var second: String? = "match" + controller.beginSwitch(viewer.playerId, viewer.session, Any(), Any()) { second } + second = null + controller.refreshAll() + assertTrue(viewer.visible.isEmpty()) + controller.close() + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt new file mode 100644 index 0000000..fe54e6c --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt @@ -0,0 +1,79 @@ +package gg.grounds.proxy.velocity.announcements + +import com.google.gson.JsonParser +import java.util.Locale +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AnnouncementLayoutTest { + @Test + fun `shared seeds validate and all languages respect single pill width`() { + val contract = + javaClass + .getResourceAsStream("/announcements/announcements-v1.json")!! + .bufferedReader() + .readText() + val settings = + AnnouncementSettings.parse( + JsonParser.parseString(contract).asJsonObject.get("seeds").toString() + ) + assertEquals(3, settings.announcements.size) + for (language in listOf("en", "de", "fr", "es")) { + val groups = + AnnouncementLayout.groups(settings, "lobby", Locale.forLanguageTag(language)) + assertTrue(groups.isNotEmpty()) + assertTrue(groups.all { it.size <= 2 }) + } + assertTrue(AnnouncementLayout.groups(settings, "game", Locale.ENGLISH).isEmpty()) + val groups = listOf(listOf(1), listOf(2), listOf(3)) + assertEquals(listOf(1), AnnouncementLayout.groupAt(groups, 9999)) + assertEquals(listOf(2), AnnouncementLayout.groupAt(groups, 10000)) + assertEquals(listOf(3), AnnouncementLayout.groupAt(groups, 20000)) + } + + @Test + fun `JSON numeric schema versions have identical semantics to the portal`() { + assertTrue( + AnnouncementSettings.parse("""{"schemaVersion":1.0,"announcements":[]}""") + .announcements + .isEmpty() + ) + } + + @Test + fun `rejects incorrect primitive types unsupported unicode and missing fallback`() { + assertThrows(IllegalArgumentException::class.java) { + AnnouncementSettings.parse("""{"schemaVersion":"1","announcements":[]}""") + } + assertThrows(IllegalArgumentException::class.java) { + AnnouncementSettings.parse("""{"schemaVersion":2,"announcements":[]}""") + } + } + + @Test + fun `jsonb whitespace does not invalidate a canonically valid document`() { + val entry = + mapOf( + "id" to "7c354d9c-6b16-4f06-9f15-000000000001", + "name" to "x", + "enabled" to true, + "text" to mapOf("en" to "abc"), + "address" to null, + ) + val rows = + (0..31).map { i -> + entry + + mapOf( + "id" to "7c354d9c-6b16-4f06-9f15-${i.toString().padStart(12,'0')}", + "serverTypes" to (0..31).map { j -> "r${i}_${j}".padEnd(56, 'x') }, + ) + } + val doc = mapOf("schemaVersion" to 1, "announcements" to rows) + val compact = com.google.gson.GsonBuilder().serializeNulls().create().toJson(doc) + val spaced = + com.google.gson.GsonBuilder().serializeNulls().setPrettyPrinting().create().toJson(doc) + assertTrue(compact.toByteArray().size < 65536) + assertTrue(spaced.toByteArray().size > 65536) + assertEquals(32, AnnouncementSettings.parse(spaced).announcements.size) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizerTest.kt new file mode 100644 index 0000000..fa6a8e2 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSynchronizerTest.kt @@ -0,0 +1,48 @@ +package gg.grounds.proxy.velocity.announcements + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AnnouncementSynchronizerTest { + @Test + fun `missed NATS heals on next poll and timeout preserves last good`() { + val empty = AnnouncementSettings(emptyList()) + var fail = false + var rev: Long? = null + val applied = mutableListOf() + val sync = + AnnouncementSynchronizer( + { known -> + rev = known + if (fail) error("unavailable") + AnnouncementSnapshot.Changed(8, empty) + }, + { applied += it }, + {}, + ) + sync.refresh() + assertEquals(listOf(empty), applied) + assertNull(rev) + fail = true + sync.refresh() + assertEquals(1, applied.size) + fail = false + sync.refresh() + assertEquals(8L, rev) + sync.close() + sync.refresh() + assertEquals(2, applied.size) + } + + @Test + fun `missing clears while unchanged leaves current snapshot alone`() { + var response: AnnouncementSnapshot = AnnouncementSnapshot.Changed(1, null) + var calls = 0 + val sync = AnnouncementSynchronizer({ response }, { calls++ }, {}) + sync.refresh() + assertEquals(1, calls) + response = AnnouncementSnapshot.Unchanged + sync.refresh() + assertEquals(1, calls) + } +} diff --git a/velocity/src/test/resources/announcements/announcements-v1.json b/velocity/src/test/resources/announcements/announcements-v1.json new file mode 100644 index 0000000..9b270f4 --- /dev/null +++ b/velocity/src/test/resources/announcements/announcements-v1.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": 1, + "seeds": { + "schemaVersion": 1, + "announcements": [ + { + "id": "7c354d9c-6b16-4f06-9f15-000000000001", + "name": "Discord", + "enabled": true, + "serverTypes": [ + "lobby" + ], + "text": { + "en": "Join our community", + "de": "Komm in unsere Community", + "fr": "Rejoins notre communauté", + "es": "Únete a nuestra comunidad" + }, + "address": "GRNDS.LINK/DISCORD" + }, + { + "id": "7c354d9c-6b16-4f06-9f15-000000000002", + "name": "Shop", + "enabled": true, + "serverTypes": [ + "lobby" + ], + "text": { + "en": "Support Grounds", + "de": "Unterstütze Grounds", + "fr": "Soutiens Grounds", + "es": "Apoya a Grounds" + }, + "address": "SHOP.GROUNDS.GG" + }, + { + "id": "7c354d9c-6b16-4f06-9f15-000000000003", + "name": "Tournaments", + "enabled": true, + "serverTypes": [ + "lobby" + ], + "text": { + "en": "Compete in tournaments", + "de": "Tritt in Turnieren an", + "fr": "Participe aux tournois", + "es": "Compite en torneos" + }, + "address": "GRNDS.LINK/TOURNAMENTS" + } + ] + }, + "valid": [ + { + "schemaVersion": 1, + "announcements": [] + } + ], + "invalid": [ + { + "schemaVersion": 2, + "announcements": [] + } + ], + "layoutCases": [ + { + "text": "abc", + "width": 23 + }, + { + "text": "i", + "width": 7 + }, + { + "text": "ü", + "width": 11 + } + ] +} diff --git a/velocity/src/test/resources/announcements/manifest.json b/velocity/src/test/resources/announcements/manifest.json new file mode 100644 index 0000000..7f8a0f5 --- /dev/null +++ b/velocity/src/test/resources/announcements/manifest.json @@ -0,0 +1,7 @@ +{ + "sourceRevision": "c1a92946eb4e781d815e5a60daf9ae76556168fe", + "sha256": { + "metrics-v1.json": "91d81decc2b53d1917d19d114ce94b27692c6b09e7aee892734e8e931c081899", + "announcements-v1.json": "231e52f0bf90a2ae49efe2564158b409e9c72390377b6d7d0224007e3d1ba3a0" + } +} diff --git a/velocity/src/test/resources/announcements/metrics-v1.json b/velocity/src/test/resources/announcements/metrics-v1.json new file mode 100644 index 0000000..6b3ad04 --- /dev/null +++ b/velocity/src/test/resources/announcements/metrics-v1.json @@ -0,0 +1,557 @@ +{ + "schemaVersion": 1, + "source": { + "clientSha256": "4508d006323f24fa02876310c192d739af56516eb259000ac50f0909a68c9a2d", + "minecraft": "26.3", + "font": "minecraft:default", + "uniform": false + }, + "layout": { + "maxWidth": 400, + "padding": 3, + "cap": 2, + "runGap": 8, + "pillGap": 8, + "maxPerGroup": 2, + "rotationMillis": 10000 + }, + "colors": { + "text": 15986922, + "address": 15185995, + "background": 1580320 + }, + "advances": { + " ": 4, + "!": 2, + "\"": 4, + "#": 6, + "$": 6, + "%": 6, + "&": 6, + "'": 2, + "(": 4, + ")": 4, + "*": 4, + "+": 6, + ",": 2, + "-": 6, + ".": 2, + "/": 6, + "0": 6, + "1": 6, + "2": 6, + "3": 6, + "4": 6, + "5": 6, + "6": 6, + "7": 6, + "8": 6, + "9": 6, + ":": 2, + ";": 2, + "<": 5, + "=": 6, + ">": 5, + "?": 6, + "@": 7, + "A": 6, + "B": 6, + "C": 6, + "D": 6, + "E": 6, + "F": 6, + "G": 6, + "H": 6, + "I": 4, + "J": 6, + "K": 6, + "L": 6, + "M": 6, + "N": 6, + "O": 6, + "P": 6, + "Q": 6, + "R": 6, + "S": 6, + "T": 6, + "U": 6, + "V": 6, + "W": 6, + "X": 6, + "Y": 6, + "Z": 6, + "[": 4, + "\\": 6, + "]": 4, + "^": 6, + "_": 6, + "`": 3, + "a": 6, + "b": 6, + "c": 6, + "d": 6, + "e": 6, + "f": 5, + "g": 6, + "h": 6, + "i": 2, + "j": 6, + "k": 5, + "l": 3, + "m": 6, + "n": 6, + "o": 6, + "p": 6, + "q": 6, + "r": 6, + "s": 6, + "t": 4, + "u": 6, + "v": 6, + "w": 6, + "x": 6, + "y": 6, + "z": 6, + "{": 4, + "|": 2, + "}": 4, + "~": 7, + "¡": 2, + "¢": 6, + "£": 6, + "¤": 8, + "¥": 6, + "¦": 2, + "¨": 4, + "©": 8, + "ª": 5, + "«": 7, + "¬": 6, + "­": 4, + "®": 8, + "¯": 6, + "°": 5, + "±": 6, + "²": 5, + "³": 5, + "´": 3, + "µ": 6, + "¶": 7, + "·": 2, + "¹": 4, + "º": 5, + "»": 7, + "¼": 8, + "½": 8, + "¾": 8, + "¿": 6, + "À": 6, + "Á": 6, + "Â": 6, + "Ã": 6, + "Ä": 6, + "Å": 6, + "Æ": 10, + "Ç": 6, + "È": 6, + "É": 6, + "Ê": 6, + "Ë": 6, + "Ì": 4, + "Í": 4, + "Î": 4, + "Ï": 4, + "Ð": 7, + "Ñ": 6, + "Ò": 6, + "Ó": 6, + "Ô": 6, + "Õ": 6, + "Ö": 6, + "×": 6, + "Ø": 6, + "Ù": 6, + "Ú": 6, + "Û": 6, + "Ü": 6, + "Ý": 6, + "Þ": 6, + "ß": 6, + "à": 6, + "á": 6, + "â": 6, + "ã": 6, + "ä": 6, + "å": 6, + "æ": 10, + "ç": 6, + "è": 6, + "é": 6, + "ê": 6, + "ë": 6, + "ì": 3, + "í": 3, + "î": 4, + "ï": 4, + "ð": 6, + "ñ": 6, + "ò": 6, + "ó": 6, + "ô": 6, + "õ": 6, + "ö": 6, + "÷": 6, + "ø": 6, + "ù": 6, + "ú": 6, + "û": 6, + "ü": 6, + "ý": 6, + "þ": 6, + "ÿ": 6, + "Ā": 6, + "ā": 6, + "Ă": 6, + "ă": 6, + "Ą": 6, + "ą": 6, + "Ć": 6, + "ć": 6, + "Ĉ": 6, + "ĉ": 6, + "Ċ": 6, + "ċ": 6, + "Č": 6, + "č": 6, + "Ď": 6, + "ď": 8, + "Đ": 7, + "đ": 7, + "Ē": 6, + "ē": 6, + "Ĕ": 6, + "ĕ": 6, + "Ė": 6, + "ė": 6, + "Ę": 6, + "ę": 6, + "Ě": 6, + "ě": 6, + "Ĝ": 6, + "ĝ": 6, + "Ğ": 6, + "ğ": 6, + "Ġ": 6, + "ġ": 6, + "Ģ": 6, + "ģ": 6, + "Ĥ": 6, + "ĥ": 6, + "Ħ": 8, + "ħ": 7, + "Ĩ": 5, + "ĩ": 5, + "Ī": 4, + "ī": 4, + "Ĭ": 5, + "ĭ": 5, + "Į": 4, + "į": 3, + "İ": 4, + "ı": 2, + "IJ": 6, + "ij": 5, + "Ĵ": 6, + "ĵ": 6, + "Ķ": 6, + "ķ": 5, + "ĸ": 5, + "Ĺ": 6, + "ĺ": 3, + "Ļ": 6, + "ļ": 3, + "Ľ": 6, + "ľ": 4, + "Ŀ": 6, + "ŀ": 4, + "Ł": 7, + "ł": 5, + "Ń": 6, + "ń": 6, + "Ņ": 6, + "ņ": 6, + "Ň": 6, + "ň": 6, + "ʼn": 8, + "Ŋ": 6, + "ŋ": 6, + "Ō": 6, + "ō": 6, + "Ŏ": 6, + "ŏ": 6, + "Ő": 6, + "ő": 6, + "Œ": 10, + "œ": 10, + "Ŕ": 6, + "ŕ": 6, + "Ŗ": 6, + "ŗ": 6, + "Ř": 6, + "ř": 6, + "Ś": 6, + "ś": 6, + "Ŝ": 6, + "ŝ": 6, + "Ş": 6, + "ş": 6, + "Š": 6, + "š": 6, + "Ţ": 6, + "ţ": 4, + "Ť": 6, + "ť": 5, + "Ŧ": 6, + "ŧ": 4, + "Ũ": 6, + "ũ": 6, + "Ū": 6, + "ū": 6, + "Ŭ": 6, + "ŭ": 6, + "Ů": 6, + "ů": 6, + "Ű": 6, + "ű": 6, + "Ų": 6, + "ų": 6, + "Ŵ": 6, + "ŵ": 6, + "Ŷ": 6, + "ŷ": 6, + "Ÿ": 6, + "Ź": 6, + "ź": 6, + "Ż": 6, + "ż": 6, + "Ž": 6, + "ž": 6, + "ſ": 4, + "ƀ": 7, + "Ɓ": 8, + "Ƃ": 6, + "ƃ": 6, + "Ƅ": 7, + "ƅ": 7, + "Ɔ": 6, + "Ƈ": 7, + "ƈ": 7, + "Ɖ": 7, + "Ɗ": 8, + "Ƌ": 6, + "ƌ": 6, + "ƍ": 6, + "Ǝ": 6, + "Ə": 6, + "Ɛ": 6, + "Ƒ": 7, + "ƒ": 6, + "Ɠ": 7, + "Ɣ": 6, + "ƕ": 9, + "Ɩ": 4, + "Ɨ": 4, + "Ƙ": 6, + "ƙ": 5, + "ƚ": 4, + "ƛ": 6, + "Ɯ": 6, + "Ɲ": 7, + "ƞ": 6, + "Ɵ": 6, + "Ơ": 8, + "ơ": 8, + "Ƣ": 8, + "ƣ": 8, + "Ƥ": 8, + "ƥ": 6, + "Ʀ": 6, + "Ƨ": 6, + "ƨ": 6, + "Ʃ": 6, + "ƪ": 6, + "ƫ": 4, + "Ƭ": 7, + "ƭ": 4, + "Ʈ": 6, + "Ư": 8, + "ư": 8, + "Ʊ": 6, + "Ʋ": 6, + "Ƴ": 7, + "ƴ": 8, + "Ƶ": 6, + "ƶ": 6, + "Ʒ": 6, + "Ƹ": 6, + "ƹ": 6, + "ƺ": 6, + "ƻ": 6, + "Ƽ": 6, + "ƽ": 6, + "ƾ": 6, + "ƿ": 6, + "ǀ": 2, + "ǁ": 4, + "ǂ": 6, + "ǃ": 2, + "DŽ": 10, + "Dž": 10, + "dž": 10, + "LJ": 10, + "Lj": 10, + "lj": 8, + "NJ": 10, + "Nj": 10, + "nj": 10, + "Ǎ": 6, + "ǎ": 6, + "Ǐ": 4, + "ǐ": 4, + "Ǒ": 6, + "ǒ": 6, + "Ǔ": 6, + "ǔ": 6, + "Ǖ": 6, + "ǖ": 6, + "Ǘ": 6, + "ǘ": 6, + "Ǚ": 6, + "ǚ": 6, + "Ǜ": 6, + "ǜ": 6, + "ǝ": 6, + "Ǟ": 6, + "ǟ": 6, + "Ǡ": 6, + "ǡ": 6, + "Ǣ": 10, + "ǣ": 10, + "Ǥ": 7, + "ǥ": 7, + "Ǧ": 6, + "ǧ": 6, + "Ǩ": 6, + "ǩ": 5, + "Ǫ": 6, + "ǫ": 6, + "Ǭ": 6, + "ǭ": 6, + "Ǯ": 6, + "ǯ": 6, + "ǰ": 6, + "DZ": 10, + "Dz": 10, + "dz": 10, + "Ǵ": 6, + "ǵ": 6, + "Ƕ": 9, + "Ƿ": 6, + "Ǹ": 6, + "ǹ": 6, + "Ǻ": 6, + "ǻ": 6, + "Ǽ": 10, + "ǽ": 10, + "Ǿ": 6, + "ǿ": 6, + "Ȁ": 6, + "ȁ": 6, + "Ȃ": 6, + "ȃ": 6, + "Ȅ": 6, + "ȅ": 6, + "Ȇ": 6, + "ȇ": 6, + "Ȉ": 6, + "ȉ": 6, + "Ȋ": 5, + "ȋ": 5, + "Ȍ": 6, + "ȍ": 6, + "Ȏ": 6, + "ȏ": 6, + "Ȑ": 6, + "ȑ": 6, + "Ȓ": 6, + "ȓ": 6, + "Ȕ": 6, + "ȕ": 6, + "Ȗ": 6, + "ȗ": 6, + "Ș": 6, + "ș": 6, + "Ț": 6, + "ț": 4, + "Ȝ": 6, + "ȝ": 6, + "Ȟ": 6, + "ȟ": 6, + "Ƞ": 6, + "ȡ": 8, + "Ȣ": 6, + "ȣ": 6, + "Ȥ": 6, + "ȥ": 6, + "Ȧ": 6, + "ȧ": 6, + "Ȩ": 6, + "ȩ": 6, + "Ȫ": 6, + "ȫ": 6, + "Ȭ": 6, + "ȭ": 6, + "Ȯ": 6, + "ȯ": 6, + "Ȱ": 6, + "ȱ": 6, + "Ȳ": 6, + "ȳ": 6, + "ȴ": 4, + "ȵ": 8, + "ȶ": 5, + "ȷ": 6, + "ȸ": 10, + "ȹ": 10, + "Ⱥ": 6, + "Ȼ": 6, + "ȼ": 6, + "Ƚ": 7, + "Ⱦ": 6, + "ȿ": 6, + "ɀ": 6, + "Ɂ": 6, + "ɂ": 6, + "Ƀ": 7, + "Ʉ": 8, + "Ʌ": 6, + "Ɇ": 6, + "ɇ": 7, + "Ɉ": 7, + "ɉ": 7, + "Ɋ": 7, + "ɋ": 7, + "Ɍ": 7, + "ɍ": 7, + "Ɏ": 8, + "ɏ": 8, + "–": 7, + "—": 9, + "‘": 3, + "’": 3, + "‚": 3, + "“": 5, + "”": 5, + "„": 5 + } +} From 5ae6e81ef2ed5815ab1528ea1253f7c5ca85a7ff Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 16:42:49 +0100 Subject: [PATCH 3/8] build: pin immutable announcement dependencies --- velocity/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 6e239ad..fc5d145 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,7 +14,7 @@ repositories { dependencies { implementation(project(":api")) - implementation("gg.grounds:library-minigame-api-hud:feat-portal-announcements-SNAPSHOT") + implementation("gg.grounds:library-minigame-api-hud:0.6.1-announcements-c05cd2a") // service-config answers REST; the MOTD store parses its JSON with gson, the // way plugin-match's and plugin-social's clients do. implementation("com.google.code.gson:gson:2.15.0") From 2bab91f05ccbb5c7125db99d7c01f4fe931257f9 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 17:03:10 +0100 Subject: [PATCH 4/8] build: complete announcement repository and formatting checks --- velocity/build.gradle.kts | 2 +- .../proxy/velocity/GroundsProxyPlugin.kt | 7 +- .../announcements/AnnouncementSettings.kt | 85 ++++++++++--------- 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index fc5d145..913ba81 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id("gg.grounds.velocity") version "0.1.1" } repositories { maven { - url = uri("https://maven.pkg.github.com/groundsgg/*") + url = uri("https://maven.pkg.github.com/groundsgg/library-minigame-api") credentials { username = providers.gradleProperty("github.user").orNull ?: System.getenv("GITHUB_ACTOR") diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt index 3a8e2c5..f62b880 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -226,9 +226,10 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { /** The per-server counts of a fresh broadcast that carried them, as [NetworkPlayerCounts]. */ private val broadcastServerCounts: NetworkPlayerCounts? - get() = cachedPlayerCount?.let { - broadcastServerCounts(it.total, it.servers, it.receivedAtNanos, System.nanoTime()) - } + get() = + cachedPlayerCount?.let { + broadcastServerCounts(it.total, it.servers, it.receivedAtNanos, System.nanoTime()) + } private var countSubscription: Subscription? = null private var metrics: ProxyMetrics? = null diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt index 383cb1d..dab6da9 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementSettings.kt @@ -34,51 +34,54 @@ data class AnnouncementSettings(val announcements: List) { ) val entries = root["announcements"].asJsonArray require(entries.size() <= 32) - val announcements = entries.map { node -> - val e = node.asJsonObject - e.fields(setOf("id", "name", "enabled", "serverTypes", "text", "address")) - val id = e["id"].string().lowercase(Locale.ROOT) - require( - id.matches( - Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + val announcements = + entries.map { node -> + val e = node.asJsonObject + e.fields(setOf("id", "name", "enabled", "serverTypes", "text", "address")) + val id = e["id"].string().lowercase(Locale.ROOT) + require( + id.matches( + Regex( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + ) + ) ) - ) - val name = e["name"].string() - require(name.isNotBlank() && name.length <= 80) - require(e["enabled"].asJsonPrimitive.isBoolean) - val enabled = e["enabled"].asBoolean - val roles = - e["serverTypes"].asJsonArray.map { - it.string().also { role -> - require(role.matches(Regex("[a-z0-9][a-z0-9._-]{0,63}"))) + val name = e["name"].string() + require(name.isNotBlank() && name.length <= 80) + require(e["enabled"].asJsonPrimitive.isBoolean) + val enabled = e["enabled"].asBoolean + val roles = + e["serverTypes"].asJsonArray.map { + it.string().also { role -> + require(role.matches(Regex("[a-z0-9][a-z0-9._-]{0,63}"))) + } + } + require( + roles.size <= 32 && + roles.distinct().size == roles.size && + (!enabled || roles.isNotEmpty()) + ) + val texts = + e["text"].asJsonObject.entrySet().associate { (lang, value) -> + require(lang in setOf("en", "de", "fr", "es")) + lang to value.string().also { validateText(it, 256) } + } + require("en" in texts) + val address = + e["address"] + .takeUnless { it.isJsonNull } + ?.string() + ?.also { validateText(it, 128) } + Announcement(id, name, enabled, roles, texts, address).also { entry -> + for (lang in texts.keys) require( + gg.grounds.hud.HudPills.width( + AnnouncementLayout.pill(entry, Locale.forLanguageTag(lang)) + ) <= HudMetrics.layout("maxWidth") + ) { + "Announcement exceeds HUD width" } - } - require( - roles.size <= 32 && - roles.distinct().size == roles.size && - (!enabled || roles.isNotEmpty()) - ) - val texts = - e["text"].asJsonObject.entrySet().associate { (lang, value) -> - require(lang in setOf("en", "de", "fr", "es")) - lang to value.string().also { validateText(it, 256) } - } - require("en" in texts) - val address = - e["address"] - .takeUnless { it.isJsonNull } - ?.string() - ?.also { validateText(it, 128) } - Announcement(id, name, enabled, roles, texts, address).also { entry -> - for (lang in texts.keys) require( - gg.grounds.hud.HudPills.width( - AnnouncementLayout.pill(entry, Locale.forLanguageTag(lang)) - ) <= HudMetrics.layout("maxWidth") - ) { - "Announcement exceeds HUD width" } } - } require(announcements.map { it.id }.distinct().size == announcements.size) val canonical = com.google.gson From 10517db72bee40a5be7eeb22ea021c2127c13329 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 17:08:19 +0100 Subject: [PATCH 5/8] ci: pass existing packages token for cross-repository HUD resolution --- .github/workflows/ci.yml | 2 ++ .github/workflows/docker-gradle-build-push.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 330a4b8..252761c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,3 +11,5 @@ on: jobs: reusable: uses: groundsgg/.github/.github/workflows/gradle-ci.yml@main + secrets: + PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }} diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml index 98b9bb0..3096218 100644 --- a/.github/workflows/docker-gradle-build-push.yml +++ b/.github/workflows/docker-gradle-build-push.yml @@ -20,3 +20,5 @@ permissions: jobs: reusable: uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main + secrets: + PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }} From 4f38a8ce0ab21057376cb66f7ea00ab8471dd2c5 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 17:09:24 +0100 Subject: [PATCH 6/8] ci: retain cross-repository package access for future publishes --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0531c..b35bdd2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,3 +12,5 @@ permissions: jobs: reusable: uses: groundsgg/.github/.github/workflows/gradle-publish.yml@main + secrets: + PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }} From a64e1238a6281a3864c959ad09541a7063096c44 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 17:43:42 +0100 Subject: [PATCH 7/8] feat(announcements): separate text and link with the Mini-CD sand dot --- velocity/build.gradle.kts | 2 +- .../announcements/AnnouncementLayout.kt | 8 +++-- .../announcements/AnnouncementLayoutTest.kt | 35 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 913ba81..71bab42 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,7 +14,7 @@ repositories { dependencies { implementation(project(":api")) - implementation("gg.grounds:library-minigame-api-hud:0.6.1-announcements-c05cd2a") + implementation("gg.grounds:library-minigame-api-hud:0.6.1-announcement-dot-ea84b96") // service-config answers REST; the MOTD store parses its JSON with gson, the // way plugin-match's and plugin-social's clients do. implementation("com.google.code.gson:gson:2.15.0") diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt index e25d575..dcc7845 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt @@ -11,8 +11,12 @@ object AnnouncementLayout { HudPill( buildList { add(HudRun(entry.caption(locale), 0xF3F0EA)) - entry.address?.let { add(HudRun(it, 0xE7B84B, true)) } - } + entry.address?.let { + add(HudRun(" · ", 0xC1B9AA)) + add(HudRun(it, 0xE7B84B, true)) + } + }, + runGap = 0, ) fun groups( diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt index fe54e6c..6372a6f 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt @@ -6,6 +6,41 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class AnnouncementLayoutTest { + @Test + fun `caption and link share a pill with a sand separator and ordinary spaces`() { + val settings = + AnnouncementSettings.parse( + """{"schemaVersion":1,"announcements":[{"id":"7c354d9c-6b16-4f06-9f15-000000000001","name":"test","enabled":true,"serverTypes":["lobby"],"text":{"en":"abc"},"address":"example.gg"}]}""" + ) + val entry = settings.announcements.single() + val pill = AnnouncementLayout.pill(entry, Locale.ENGLISH) + assertEquals(listOf("abc", " · ", "example.gg"), pill.runs.map { it.text }) + assertEquals(listOf(0xF3F0EA, 0xC1B9AA, 0xE7B84B), pill.runs.map { it.rgb }) + assertEquals(listOf(false, false, true), pill.runs.map { it.bold }) + assertEquals(96, gg.grounds.hud.HudPills.width(pill)) + val withoutLink = AnnouncementLayout.pill(entry.copy(address = null), Locale.ENGLISH) + assertEquals(listOf("abc"), withoutLink.runs.map { it.text }) + assertEquals(23, gg.grounds.hud.HudPills.width(withoutLink)) + val overwide = + settings.copy( + announcements = + listOf( + entry.copy(text = mapOf("en" to "W".repeat(55)), address = "W".repeat(8)) + ) + ) + assertEquals( + 401, + gg.grounds.hud.HudPills.width( + AnnouncementLayout.pill(overwide.announcements.single(), Locale.ENGLISH) + ), + ) + val json = + com.google.gson + .Gson() + .toJson(mapOf("schemaVersion" to 1, "announcements" to overwide.announcements)) + assertThrows(IllegalArgumentException::class.java) { AnnouncementSettings.parse(json) } + } + @Test fun `shared seeds validate and all languages respect single pill width`() { val contract = From 2d2ef36047ac10e7c875c88e940f97171cd54d80 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 3 Nov 2026 19:43:13 +0100 Subject: [PATCH 8/8] feat(announcements): use the larger native sand bullet --- velocity/build.gradle.kts | 2 +- .../proxy/velocity/announcements/AnnouncementLayout.kt | 2 +- .../proxy/velocity/announcements/AnnouncementLayoutTest.kt | 6 +++--- velocity/src/test/resources/announcements/manifest.json | 4 ++-- velocity/src/test/resources/announcements/metrics-v1.json | 3 ++- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 71bab42..c01d136 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,7 +14,7 @@ repositories { dependencies { implementation(project(":api")) - implementation("gg.grounds:library-minigame-api-hud:0.6.1-announcement-dot-ea84b96") + implementation("gg.grounds:library-minigame-api-hud:0.6.1-announcement-bullet-75f53d5") // service-config answers REST; the MOTD store parses its JSON with gson, the // way plugin-match's and plugin-social's clients do. implementation("com.google.code.gson:gson:2.15.0") diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt index dcc7845..0e71046 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayout.kt @@ -12,7 +12,7 @@ object AnnouncementLayout { buildList { add(HudRun(entry.caption(locale), 0xF3F0EA)) entry.address?.let { - add(HudRun(" · ", 0xC1B9AA)) + add(HudRun(" • ", 0xC1B9AA)) add(HudRun(it, 0xE7B84B, true)) } }, diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt index 6372a6f..6b77f41 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/announcements/AnnouncementLayoutTest.kt @@ -14,10 +14,10 @@ class AnnouncementLayoutTest { ) val entry = settings.announcements.single() val pill = AnnouncementLayout.pill(entry, Locale.ENGLISH) - assertEquals(listOf("abc", " · ", "example.gg"), pill.runs.map { it.text }) + assertEquals(listOf("abc", " • ", "example.gg"), pill.runs.map { it.text }) assertEquals(listOf(0xF3F0EA, 0xC1B9AA, 0xE7B84B), pill.runs.map { it.rgb }) assertEquals(listOf(false, false, true), pill.runs.map { it.bold }) - assertEquals(96, gg.grounds.hud.HudPills.width(pill)) + assertEquals(97, gg.grounds.hud.HudPills.width(pill)) val withoutLink = AnnouncementLayout.pill(entry.copy(address = null), Locale.ENGLISH) assertEquals(listOf("abc"), withoutLink.runs.map { it.text }) assertEquals(23, gg.grounds.hud.HudPills.width(withoutLink)) @@ -29,7 +29,7 @@ class AnnouncementLayoutTest { ) ) assertEquals( - 401, + 402, gg.grounds.hud.HudPills.width( AnnouncementLayout.pill(overwide.announcements.single(), Locale.ENGLISH) ), diff --git a/velocity/src/test/resources/announcements/manifest.json b/velocity/src/test/resources/announcements/manifest.json index 7f8a0f5..cd3a698 100644 --- a/velocity/src/test/resources/announcements/manifest.json +++ b/velocity/src/test/resources/announcements/manifest.json @@ -1,7 +1,7 @@ { - "sourceRevision": "c1a92946eb4e781d815e5a60daf9ae76556168fe", + "sourceRevision": "75f53d51943194609e01516c7186270354965273", "sha256": { - "metrics-v1.json": "91d81decc2b53d1917d19d114ce94b27692c6b09e7aee892734e8e931c081899", + "metrics-v1.json": "457cbaa5c68a458da8d8da94bbc187bf39dfa73fafd7c36e9bfb0ba7a96fa77e", "announcements-v1.json": "231e52f0bf90a2ae49efe2564158b409e9c72390377b6d7d0224007e3d1ba3a0" } } diff --git a/velocity/src/test/resources/announcements/metrics-v1.json b/velocity/src/test/resources/announcements/metrics-v1.json index 6b3ad04..18a324d 100644 --- a/velocity/src/test/resources/announcements/metrics-v1.json +++ b/velocity/src/test/resources/announcements/metrics-v1.json @@ -552,6 +552,7 @@ "‚": 3, "“": 5, "”": 5, - "„": 5 + "„": 5, + "•": 3 } }