Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/docker-gradle-build-push.yml
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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) / `:<semver>` (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.
26 changes: 17 additions & 9 deletions api/src/main/kotlin/gg/grounds/proxy/api/PlayerSessionQuery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<OnlinePlayer>

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<String>
}

data class PlayerSessionInfo(
val playerId: UUID,
Expand Down
27 changes: 26 additions & 1 deletion api/src/main/kotlin/gg/grounds/proxy/api/ProxyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
/**
* 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<String>

fun resolvePlayerId(name: String): UUID?

Expand All @@ -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
}
}
4 changes: 4 additions & 0 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> {
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? {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Entry>()

/** Cached names for [prefix], or the result of [load] — which is only called on a miss. */
fun get(prefix: String, load: () -> List<String>): List<String> {
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<String>, 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<String>, remote: List<String>, limit: Int): List<String> =
(local + remote).distinct().take(limit)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}