diff --git a/README.md b/README.md index b35f8a1..f90c6a3 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,16 @@ than a gap. The NATS auth-callout scopes each pod to the subjects declared in its bundle `events:` block, so `proxy.system.*` and `proxy.transfer.*` must be listed there — an undeclared subject is denied and the message vanishes. +## Maintenance in the server list + +During an active maintenance window, every proxy reports a red `Wartungsarbeiten` +label in the server list's protocol/version field. Once the window ends or is +disabled, the normal version and player-count display returns on the next ping. + +The maintenance document's `motd` is optional in effect: set it to an empty string +to keep the normal MOTD. A nonblank value overrides the description only while +maintenance is active. New maintenance drafts default to an empty override. + ## Build ```bash 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 882d90b..caee0f3 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -15,6 +15,7 @@ import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.proxy.Player import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.proxy.server.ServerPing import com.velocitypowered.api.scheduler.ScheduledTask import gg.grounds.BuildInfo import gg.grounds.i18n.Translations @@ -50,6 +51,7 @@ import gg.grounds.proxy.velocity.tab.PlayerDisplayQueryImpl import gg.grounds.proxy.velocity.tab.TabList import io.nats.client.Subscription import java.time.Duration +import java.time.Instant import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import net.kyori.adventure.text.minimessage.MiniMessage @@ -526,8 +528,10 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { fun onProxyPing(event: ProxyPingEvent) { val count = networkPlayerCount val motd = motdManager - val maintenanceMotd = maintenanceStore?.let { maintenance.motd() } - if (count == null && motd == null && maintenanceMotd == null) return + val now = Instant.now() + val maintenanceMotd = maintenanceStore?.let { maintenance.motd(now) } + val maintenanceProtocol = maintenanceStore?.let { maintenance.protocolName(now) } + if (count == null && motd == null && maintenanceProtocol == null) return val builder = event.ping.asBuilder() count?.let { builder.onlinePlayers(it) } @@ -540,6 +544,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { } } maintenanceMotd?.let { builder.description(MiniMessage.miniMessage().deserialize(it)) } + maintenanceProtocol?.let { builder.version(ServerPing.Version(-1, it)) } event.ping = builder.build() } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt index a91aa58..232ae28 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt @@ -146,7 +146,7 @@ internal constructor( } val motd = string("motd").also { - if (it.isEmpty() || it.length > 4096) + if (it.length > 4096) throw MaintenanceStoreException("Maintenance motd is invalid") } val announcement = diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt index 54ff1dc..ede605c 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt @@ -90,15 +90,13 @@ class MaintenanceEngine(private val clock: Clock = Clock.systemUTC()) { return crossed.minOrNull()?.let(::listOf) ?: emptyList() } + @Synchronized + fun protocolName(now: Instant = clock.instant()): String? = + if (activeAt(now)) "§cWartungsarbeiten" else null + @Synchronized fun motd(now: Instant = clock.instant()): String? = - document - ?.takeIf { - it.enabled && - !now.isBefore(it.startsAt) && - (it.endsAt == null || now.isBefore(it.endsAt)) - } - ?.let { render(it.motd, now) } + document?.takeIf { activeAt(now) && it.motd.isNotBlank() }?.let { render(it.motd, now) } @Synchronized fun announcementMessage(now: Instant = clock.instant()): String? = diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt index dbb382d..595a4d7 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt @@ -15,6 +15,15 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows class MaintenanceConfigStoreTest { + @Test + fun `an empty maintenance motd preserves the normal server description`() { + val valid = json("123e4567-e89b-12d3-a456-426614174000") + assertEquals( + "", + MaintenanceConfigStore.parse(valid.replace("\"motd\":\"motd\"", "\"motd\":\"\"")).motd, + ) + } + @Test fun `accepts canonical UUIDs of every version including floodgate UUIDs`() { val document = MaintenanceConfigStore.parse(json("123e4567-e89b-12d3-a456-426614174000")) diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt index cec369d..18db7d8 100644 --- a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt @@ -12,6 +12,34 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class MaintenanceEngineTest { + @Test + fun `maintenance protocol label follows the window on every proxy without overriding the motd`() { + val start = Instant.parse("2026-10-20T20:00:00Z") + val document = active(start, endsAt = start.plusSeconds(60)).copy(motd = "") + val proxies = listOf(MaintenanceEngine(), MaintenanceEngine()) + proxies.forEach { engine -> + engine.adopt(document, start.minusSeconds(1)) + assertEquals(null, engine.protocolName(start.minusSeconds(1))) + assertEquals("§cWartungsarbeiten", engine.protocolName(start)) + assertEquals(null, engine.motd(start)) + assertEquals(null, engine.protocolName(start.plusSeconds(60))) + engine.adopt(document.copy(enabled = false), start) + assertEquals(null, engine.protocolName(start)) + } + } + + @Test + fun `an optional maintenance motd is rendered only during the active window`() { + val start = Instant.parse("2026-10-20T20:00:00Z") + val engine = MaintenanceEngine() + engine.adopt(active(start, endsAt = start.plusSeconds(60)), start) + assertEquals(null, engine.motd(start.minusSeconds(1))) + assertEquals("motd", engine.motd(start)) + assertEquals(null, engine.motd(start.plusSeconds(60))) + engine.adopt(active(start).copy(motd = " "), start) + assertEquals(null, engine.motd(start)) + } + private val id = UUID.fromString("00000000-0000-0000-0000-000000000001") private val player = UUID.fromString("00000000-0000-0000-0000-000000000002")