diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml new file mode 100644 index 0000000..98b9bb0 --- /dev/null +++ b/.github/workflows/docker-gradle-build-push.yml @@ -0,0 +1,22 @@ +name: Docker Build + +# Publishes ghcr.io/groundsgg/plugin-proxy (edge on main, semver on tag). +# The image carries the shaded JAR at /jar/plugin.jar so the platform bundle +# can pull the plugin into the velocity proxy via the plugin-velocity-jar chart. + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + +permissions: + packages: write + contents: write + actions: write + +jobs: + reusable: + uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1a23bd6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1 +# +# Builds the plugin-proxy Velocity-plugin JAR and packages it for the +# platform-test environment. The output image carries the JAR at +# /jar/plugin.jar — the shape the `plugin-velocity-jar` Helm chart expects +# (oci://ghcr.io/groundsgg/charts/plugin-velocity-jar). Velocity proxy pods +# in a per-engineer vCluster fetch this image's JAR via the chart's +# init-container + httpd indirection. +# +# Pushed as `ghcr.io/groundsgg/plugin-proxy:edge` (main) / `:` (tag) +# by .github/workflows/docker-gradle-build-push.yml. + +FROM eclipse-temurin:25-jdk AS build +WORKDIR /src + +# GitHub Packages credentials for the gg.grounds.velocity convention plugin. +# The token comes from the `github_token` build secret (never a build-arg — +# that would leak it into the layer history). +ARG GITHUB_USER + +# Copy gradle wrapper + root config first so dependency caches stay warm +# across source-only changes. +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./ + +COPY api/ api/ +COPY velocity/ velocity/ + +# `:velocity:build` produces the shaded plugin JAR (the api module and the +# NATS client are bundled into it — the proxy plugin owns the +# ProxyServiceRegistry that plugin-chat resolves at runtime). +RUN --mount=type=secret,id=github_token,required=true \ + /bin/sh -euc '\ + : "${GITHUB_USER:?GITHUB_USER build arg is required}"; \ + token="$(cat /run/secrets/github_token)"; \ + ./gradlew --no-daemon :velocity:build \ + -Pgithub.user="${GITHUB_USER}" \ + -Pgithub.token="${token}" \ + ' + +RUN mkdir -p /out && \ + cp "$(ls -S /src/velocity/build/libs/*.jar | head -n1)" /out/plugin.jar + +FROM alpine:3 +RUN mkdir -p /jar +COPY --from=build /out/plugin.jar /jar/plugin.jar +# No ENTRYPOINT — the plugin-velocity-jar chart's init-container `cp`s +# /jar/plugin.jar out, then the chart's main container (busybox httpd) +# serves the JAR. This image only carries data. diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerSessionQuery.kt b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerSessionQuery.kt index 100cbec..cbdf2b0 100644 --- a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerSessionQuery.kt +++ b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerSessionQuery.kt @@ -2,20 +2,28 @@ package gg.grounds.proxy.api import java.util.UUID +/** + * Cross-proxy player lookup. A proxy knows only the players connected to itself; this answers for + * the whole network. + * + * Registered into the [ProxyServiceRegistry] by whichever plugin owns presence — today + * plugin-player, backed by service-player. With nothing registered, [ProxyService] degrades to + * local-only answers. + */ interface PlayerSessionQuery { - fun getOnlinePlayers(): Collection - fun getSession(playerId: UUID): PlayerSessionInfo? fun resolveByName(name: String): PlayerSessionInfo? -} -data class OnlinePlayer( - val playerId: UUID, - val name: String, - val proxyId: String?, - val server: String?, -) + /** + * Online names starting with [prefix], at most [limit] of them. + * + * A prefix search, not a roster dump: this feeds tab-complete, which fires on every keystroke, + * so "all online players" would ship the whole network's names — thousands of times a second at + * 10k players online. Implementations clamp [limit] themselves. + */ + fun suggestNames(prefix: String, limit: Int): List +} data class PlayerSessionInfo( val playerId: UUID, diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/ProxyService.kt b/api/src/main/kotlin/gg/grounds/proxy/api/ProxyService.kt index 94864f4..b9cbc24 100644 --- a/api/src/main/kotlin/gg/grounds/proxy/api/ProxyService.kt +++ b/api/src/main/kotlin/gg/grounds/proxy/api/ProxyService.kt @@ -3,8 +3,25 @@ package gg.grounds.proxy.api import java.util.UUID import net.kyori.adventure.text.Component +/** + * What a plugin needs to act on a player who may be on *another* proxy: find them, talk to them, + * move them. Local players are answered from Velocity directly; anyone else via the registered + * [PlayerSessionQuery] (lookup) and NATS (delivery). + * + * Obtain an instance with `ProxyServiceRegistry.get(ProxyService::class.java)`. + */ interface ProxyService { - fun getOnlinePlayerNames(): Collection + /** + * Tab-complete suggestions for names starting with [prefix] — local players first, then the + * rest of the network, capped at [limit]. + * + * There is intentionally no "list every online player": tab-complete runs on every keystroke, + * and a full roster at 10k players would be a large response sent thousands of times a second. + */ + fun suggestPlayerNames( + prefix: String, + limit: Int = DEFAULT_SUGGESTION_LIMIT, + ): Collection fun resolvePlayerId(name: String): UUID? @@ -14,7 +31,15 @@ interface ProxyService { fun getPresence(playerId: UUID): PlayerPresence? + /** + * Delivers to the player wherever they are — locally, or over NATS to the proxy holding them. + */ fun sendToPlayer(targetId: UUID, message: Component) + /** Moves the player to [serverName], including when they are connected to another proxy. */ fun transferPlayer(playerId: UUID, serverName: String) + + companion object { + const val DEFAULT_SUGGESTION_LIMIT = 20 + } } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 49ccf92..5396712 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -3,4 +3,8 @@ plugins { id("gg.grounds.velocity") version "0.1.1" } dependencies { implementation(project(":api")) implementation("io.nats:jnats:2.25.3") + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4") } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt index 6c9e642..fb95339 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxyServiceImpl.kt @@ -10,16 +10,37 @@ import java.util.UUID import net.kyori.adventure.text.Component import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer -class ProxyServiceImpl(private val natsHandler: NatsHandler, private val proxy: ProxyServer) : - ProxyService { +class ProxyServiceImpl( + private val natsHandler: NatsHandler, + private val proxy: ProxyServer, + private val suggestionCache: SuggestionCache = SuggestionCache(), +) : ProxyService { private fun sessionQuery(): PlayerSessionQuery? = ProxyServiceRegistry.get(PlayerSessionQuery::class.java) - override fun getOnlinePlayerNames(): Collection { - val localNames = proxy.allPlayers.map { it.username } - val remoteNames = sessionQuery()?.getOnlinePlayers()?.map { it.name } ?: emptyList() - return (localNames + remoteNames).distinct() + /** + * Local players are free — they are already in memory. The network-wide half is the expensive + * one, so it is only asked once the player has typed enough to narrow it down, and the answer + * is cached briefly: Velocity fires tab-complete on every keystroke. + */ + override fun suggestPlayerNames(prefix: String, limit: Int): Collection { + val local = + proxy.allPlayers + .map { it.username } + .filter { it.startsWith(prefix, ignoreCase = true) } + .sorted() + if (local.size >= limit) return local.take(limit) + + val remote = + if (prefix.length >= MIN_REMOTE_PREFIX) { + suggestionCache.get(prefix) { + sessionQuery()?.suggestNames(prefix, limit) ?: emptyList() + } + } else { + emptyList() + } + return SuggestionCache.merge(local, remote, limit) } override fun resolvePlayerId(name: String): UUID? { @@ -73,4 +94,9 @@ class ProxyServiceImpl(private val natsHandler: NatsHandler, private val proxy: natsHandler.publish("proxy.transfer.$playerId", serverName) } } + + companion object { + /** One letter matches most of the network; make the player narrow it down first. */ + const val MIN_REMOTE_PREFIX = 2 + } } diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/SuggestionCache.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/SuggestionCache.kt new file mode 100644 index 0000000..02cd5b6 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/SuggestionCache.kt @@ -0,0 +1,49 @@ +package gg.grounds.proxy.velocity + +import java.util.concurrent.ConcurrentHashMap + +/** + * Short-lived cache of cross-proxy tab-complete answers, keyed by prefix. + * + * Velocity fires tab-complete on every keystroke, so one player typing an eight-letter name would + * otherwise issue eight network-wide lookups — multiplied by everyone online. Two seconds is long + * enough to cover the typing itself and short enough that a player who just joined shows up. + */ +class SuggestionCache( + private val ttlMillis: Long = DEFAULT_TTL_MS, + private val maxEntries: Int = DEFAULT_MAX_ENTRIES, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val entries = ConcurrentHashMap() + + /** Cached names for [prefix], or the result of [load] — which is only called on a miss. */ + fun get(prefix: String, load: () -> List): List { + val key = prefix.lowercase() + val now = clock() + entries[key]?.let { if (now - it.at < ttlMillis) return it.names } + + val names = load() + entries[key] = Entry(names, now) + // Every distinct prefix anyone types is a key, so it grows without bound otherwise. + if (entries.size > maxEntries) { + entries.entries.removeIf { now - it.value.at >= ttlMillis } + } + return names + } + + fun size(): Int = entries.size + + private data class Entry(val names: List, val at: Long) + + companion object { + const val DEFAULT_TTL_MS = 2_000L + const val DEFAULT_MAX_ENTRIES = 256 + + /** + * Local players first (they are certainly online and cost nothing to find), then the rest + * of the network, de-duplicated and capped. + */ + fun merge(local: List, remote: List, limit: Int): List = + (local + remote).distinct().take(limit) + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/SuggestionCacheTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/SuggestionCacheTest.kt new file mode 100644 index 0000000..ed21532 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/SuggestionCacheTest.kt @@ -0,0 +1,95 @@ +package gg.grounds.proxy.velocity + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SuggestionCacheTest { + + @Test + fun `serves a repeated prefix from cache instead of asking again`() { + // The point of the cache: tab-complete fires per keystroke, so the same prefix is asked for + // many times in a row while the player is still typing. + var loads = 0 + var now = 0L + val cache = SuggestionCache(ttlMillis = 2_000, clock = { now }) + + repeat(5) { + cache.get("dah") { + loads++ + listOf("dahendriik") + } + } + + assertEquals(1, loads) + assertEquals(listOf("dahendriik"), cache.get("dah") { emptyList() }) + } + + @Test + fun `is case-insensitive on the prefix`() { + var loads = 0 + val cache = SuggestionCache(clock = { 0 }) + + cache.get("Dah") { + loads++ + listOf("dahendriik") + } + cache.get("dAH") { + loads++ + listOf("dahendriik") + } + + assertEquals(1, loads) + } + + @Test + fun `asks again once the entry is stale`() { + var loads = 0 + var now = 0L + val cache = SuggestionCache(ttlMillis = 2_000, clock = { now }) + + cache.get("dah") { + loads++ + listOf("a") + } + now = 1_999 + cache.get("dah") { + loads++ + listOf("a") + } + now = 2_000 + cache.get("dah") { + loads++ + listOf("a") + } + + assertEquals(2, loads) + } + + @Test + fun `evicts stale entries instead of growing forever`() { + // Every distinct prefix a player types is a key. + var now = 0L + val cache = SuggestionCache(ttlMillis = 100, maxEntries = 4, clock = { now }) + + repeat(5) { i -> cache.get("p$i") { listOf("x") } } + assertTrue(cache.size() > 0) + + now = 1_000 + repeat(5) { i -> cache.get("q$i") { listOf("x") } } + + // The expired p* entries were dropped once the cache went over its bound. + assertTrue(cache.size() <= 6, "cache grew unbounded: ${cache.size()}") + } + + @Test + fun `merge puts local players first and caps the result`() { + val local = listOf("alice", "alex") + val remote = listOf("alex", "albert", "alfred", "alvin") + + val merged = SuggestionCache.merge(local, remote, limit = 4) + + // alex is on this proxy AND in the remote answer — it must appear once. + assertEquals(listOf("alice", "alex", "albert", "alfred"), merged) + } +}