From 9c0ef8de3eaafaa1e5d5dc748ae2ccf727e95352 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Tue, 4 Aug 2026 16:21:18 +0200 Subject: [PATCH] feat(presence)!: talk to service-player over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service-player publishes a REST API under /v1/players (service-player#99) and keeps its gRPC contract only until its callers move. This is one of the two. The client drops gRPC for the JDK's HttpClient and Jackson — the pair ForgeLinkClient already uses, so nothing new lands in the shaded jar and grpc-netty-shaded leaves it. Protobuf types no longer cross into the plugin either: presence now speaks its own small models, which is what lets the login outcome be a sealed result instead of a status enum the listener had to re-interpret. One behavioural difference worth naming: a login the service could not answer is Unavailable rather than a reply with an error status, so the proxy denies with "service unavailable" instead of the generic message. It could not ask, and saying so is more useful than implying the player did something wrong. BREAKING CHANGE: PLAYER_PRESENCE_GRPC_TARGET is replaced by PLAYER_SERVICE_URL. The deploy must set it in the same change that ships this jar; a proxy without it fails at startup rather than at the first login. --- README.md | 12 +- common/build.gradle.kts | 16 +- .../presence/GroundsTokenInterceptor.kt | 57 ---- .../presence/GrpcPlayerPresenceClient.kt | 257 --------------- .../presence/HttpPlayerPresenceClient.kt | 293 ++++++++++++++++++ .../player/presence/PlayerLoginResult.kt | 11 - .../grounds/player/presence/PresenceModels.kt | 74 +++++ .../grounds/player/presence/WorkloadToken.kt | 30 ++ .../presence/GroundsTokenInterceptorTest.kt | 80 ----- .../presence/HttpPlayerPresenceClientTest.kt | 248 +++++++++++++++ .../player/presence/WorkloadTokenTest.kt | 32 ++ velocity/build.gradle.kts | 1 - velocity/devspace.yaml | 2 +- .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 29 +- .../listener/PlayerConnectionListener.kt | 97 +++--- .../grounds/presence/PlayerPresenceService.kt | 161 ++++------ .../presence/PlayerSessionQueryImpl.kt | 50 ++- .../presence/PlayerSessionQueryImplTest.kt | 50 ++- 18 files changed, 856 insertions(+), 644 deletions(-) delete mode 100644 common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt delete mode 100644 common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt create mode 100644 common/src/main/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClient.kt delete mode 100644 common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt create mode 100644 common/src/main/kotlin/gg/grounds/player/presence/PresenceModels.kt create mode 100644 common/src/main/kotlin/gg/grounds/player/presence/WorkloadToken.kt delete mode 100644 common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt create mode 100644 common/src/test/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClientTest.kt create mode 100644 common/src/test/kotlin/gg/grounds/player/presence/WorkloadTokenTest.kt diff --git a/README.md b/README.md index ccc458b..3caa7ec 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,20 @@ ## Configuration -The plugin requires the gRPC target to be provided via environment variable: +The plugin requires the address of service-player. A proxy that cannot reach presence +cannot decide whether a player may join, so this is required rather than defaulted: ```bash -export PLAYER_PRESENCE_GRPC_TARGET="dns:///service-player.api.svc.cluster.local:9000" +export PLAYER_SERVICE_URL="http://service-player.api.svc.cluster.local:9000" ``` +The scheme may be omitted (`service-player.api.svc.cluster.local:9000`), matching how +the deploy sets the other service URLs; `http://` is assumed. + +Calls carry the projected workload token from `GROUNDS_TOKEN_FILE` +(default `/var/run/secrets/grounds/token`). With no token file present — local dev +against a service running `grounds.auth.enabled=false` — requests go out unauthenticated. + Optional heartbeat configuration: ```bash diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 6f91d6e..42cf730 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,17 +1,9 @@ -plugins { id("gg.grounds.grpc-conventions") } - -repositories { - maven { - url = uri("https://maven.pkg.github.com/groundsgg/*") - credentials { - username = providers.gradleProperty("github.user").get() - password = providers.gradleProperty("github.token").get() - } - } -} +plugins { id("gg.grounds.kotlin-conventions") } dependencies { - protobuf("gg.grounds:library-grpc-contracts-player:0.7.0") + // service-player is reached over HTTP now: the JDK's own client, and Jackson for the bodies — + // the same pair ForgeLinkClient already uses, so nothing new lands in the shaded jar. + implementation("tools.jackson.core:jackson-databind:3.0.4") testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt b/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt deleted file mode 100644 index 26fb8a5..0000000 --- a/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt +++ /dev/null @@ -1,57 +0,0 @@ -package gg.grounds.player.presence - -import io.grpc.CallOptions -import io.grpc.Channel -import io.grpc.ClientCall -import io.grpc.ClientInterceptor -import io.grpc.ForwardingClientCall -import io.grpc.Metadata -import io.grpc.MethodDescriptor -import java.nio.file.Files -import java.nio.file.Path - -/** - * Attaches the projected ServiceAccount JWT as `Authorization: Bearer ...` to every outgoing call. - * - * service-player runs with `grounds.auth.enabled=true` and rejects tokenless calls with - * UNAUTHENTICATED. The Grounds charts project a short-lived token (audience `grounds-services`) - * into the proxy pod and point [TOKEN_FILE_ENV] at it; kubelet rotates the file, so it is re-read - * per call rather than cached. - * - * With no token file present (local dev against a service running `grounds.auth.enabled=false`) the - * call goes out without the header. - */ -class GroundsTokenInterceptor(private val tokenLoader: () -> String? = ::loadTokenFromFile) : - ClientInterceptor { - - override fun interceptCall( - method: MethodDescriptor, - callOptions: CallOptions, - next: Channel, - ): ClientCall { - val delegate = next.newCall(method, callOptions) - return object : ForwardingClientCall.SimpleForwardingClientCall(delegate) { - override fun start(responseListener: Listener, headers: Metadata) { - tokenLoader()?.let { headers.put(AUTHORIZATION, "Bearer $it") } - super.start(responseListener, headers) - } - } - } - - companion object { - const val TOKEN_FILE_ENV = "GROUNDS_TOKEN_FILE" - const val DEFAULT_TOKEN_PATH = "/var/run/secrets/grounds/token" - - private val AUTHORIZATION: Metadata.Key = - Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) - - private fun loadTokenFromFile(): String? { - val path = Path.of(System.getenv(TOKEN_FILE_ENV) ?: DEFAULT_TOKEN_PATH) - return try { - if (Files.exists(path)) Files.readString(path).trim().ifEmpty { null } else null - } catch (_: Exception) { - null - } - } - } -} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt deleted file mode 100644 index 7121a25..0000000 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ /dev/null @@ -1,257 +0,0 @@ -package gg.grounds.player.presence - -import gg.grounds.grpc.player.CountPlayersByProxyReply -import gg.grounds.grpc.player.CountPlayersByProxyRequest -import gg.grounds.grpc.player.CountPlayersByServerReply -import gg.grounds.grpc.player.CountPlayersByServerRequest -import gg.grounds.grpc.player.GetPlayerLocaleRequest -import gg.grounds.grpc.player.GetPlayerSessionRequest -import gg.grounds.grpc.player.PlayerHeartbeatBatchReply -import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest -import gg.grounds.grpc.player.PlayerLoginRequest -import gg.grounds.grpc.player.PlayerLogoutReply -import gg.grounds.grpc.player.PlayerLogoutRequest -import gg.grounds.grpc.player.PlayerPresenceServiceGrpc -import gg.grounds.grpc.player.PlayerSessionInfo -import gg.grounds.grpc.player.ResolvePlayerNameRequest -import gg.grounds.grpc.player.SetPlayerLocaleRequest -import gg.grounds.grpc.player.SuggestPlayerNamesRequest -import gg.grounds.grpc.player.UpdatePlayerServerRequest -import io.grpc.ManagedChannel -import io.grpc.ManagedChannelBuilder -import io.grpc.Status -import io.grpc.StatusRuntimeException -import java.util.UUID -import java.util.concurrent.TimeUnit - -class GrpcPlayerPresenceClient -private constructor( - private val channel: ManagedChannel, - private val stub: PlayerPresenceServiceGrpc.PlayerPresenceServiceBlockingStub, -) : AutoCloseable { - fun tryLogin( - playerId: UUID, - playerName: String = "", - proxyId: String = "", - region: String = "", - ): PlayerLoginResult { - return try { - val reply = - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .tryPlayerLogin( - PlayerLoginRequest.newBuilder() - .setPlayerId(playerId.toString()) - .setPlayerName(playerName) - .setProxyId(proxyId) - .setRegion(region) - .build() - ) - PlayerLoginResult.Success(reply) - } catch (e: StatusRuntimeException) { - if (isServiceUnavailable(e.status.code)) { - return PlayerLoginResult.Unavailable(e.status.toString()) - } - PlayerLoginResult.Error(e.status.toString()) - } catch (e: RuntimeException) { - PlayerLoginResult.Error(e.message ?: e::class.java.name) - } - } - - /** - * [proxyId] scopes the delete to this proxy's own session: a logout that raced a proxy-to-proxy - * transfer must not remove the session the next proxy just created. Empty is legal (the service - * falls back to the old unconditional delete). - */ - fun logout(playerId: UUID, proxyId: String = ""): PlayerLogoutReply { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .playerLogout( - PlayerLogoutRequest.newBuilder() - .setPlayerId(playerId.toString()) - .setProxyId(proxyId) - .build() - ) - } catch (e: StatusRuntimeException) { - errorLogoutReply(e.status.toString()) - } catch (e: RuntimeException) { - errorLogoutReply(e.message ?: e::class.java.name) - } - } - - fun heartbeatBatch(playerIds: Collection): PlayerHeartbeatBatchReply { - return try { - val request = - PlayerHeartbeatBatchRequest.newBuilder() - .addAllPlayerIds(playerIds.map { it.toString() }) - .build() - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .playerHeartbeatBatch(request) - } catch (e: StatusRuntimeException) { - errorHeartbeatBatchReply(e.status.toString()) - } catch (e: RuntimeException) { - errorHeartbeatBatchReply(e.message ?: e::class.java.name) - } - } - - /** - * The lookups below back cross-proxy features, and they run on the command path (`/msg`, - * tab-complete). A failure means "I don't know", never an exception into Velocity's event loop - * — the caller then falls back to what it can see locally. - */ - fun getSession(playerId: UUID): PlayerSessionInfo? { - return try { - val reply = - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .getPlayerSession( - GetPlayerSessionRequest.newBuilder() - .setPlayerId(playerId.toString()) - .build() - ) - if (reply.found) reply.session else null - } catch (e: RuntimeException) { - null - } - } - - fun resolveName(playerName: String): PlayerSessionInfo? { - return try { - val reply = - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .resolvePlayerName( - ResolvePlayerNameRequest.newBuilder().setPlayerName(playerName).build() - ) - if (reply.found) reply.session else null - } catch (e: RuntimeException) { - null - } - } - - fun suggestNames(prefix: String, limit: Int): List { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .suggestPlayerNames( - SuggestPlayerNamesRequest.newBuilder().setPrefix(prefix).setLimit(limit).build() - ) - .playerNamesList - } catch (e: RuntimeException) { - emptyList() - } - } - - fun countPlayersByServer(): CountPlayersByServerReply? { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .countPlayersByServer(CountPlayersByServerRequest.newBuilder().build()) - } catch (e: RuntimeException) { - null - } - } - - fun countPlayersByProxy(): CountPlayersByProxyReply? { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .countPlayersByProxy(CountPlayersByProxyRequest.newBuilder().build()) - } catch (e: RuntimeException) { - null - } - } - - fun updateServer(playerId: UUID, serverName: String): Boolean { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .updatePlayerServer( - UpdatePlayerServerRequest.newBuilder() - .setPlayerId(playerId.toString()) - .setServerName(serverName) - .build() - ) - .updated - } catch (e: RuntimeException) { - false - } - } - - override fun close() { - channel.shutdown() - try { - if (!channel.awaitTermination(3, TimeUnit.SECONDS)) { - channel.shutdownNow() - channel.awaitTermination(3, TimeUnit.SECONDS) - } - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - channel.shutdownNow() - } - } - - /** The player's stored language tag, or null when they have chosen none. Never throws. */ - fun getLocale(playerId: UUID): String? { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .getPlayerLocale( - GetPlayerLocaleRequest.newBuilder().setPlayerId(playerId.toString()).build() - ) - .locale - .ifEmpty { null } - } catch (e: RuntimeException) { - null - } - } - - /** Persists (or, with a blank tag, clears) the player's language. Never throws. */ - fun setLocale(playerId: UUID, locale: String): Boolean { - return try { - stub - .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .setPlayerLocale( - SetPlayerLocaleRequest.newBuilder() - .setPlayerId(playerId.toString()) - .setLocale(locale) - .build() - ) - .updated - } catch (e: RuntimeException) { - false - } - } - - companion object { - fun create(target: String): GrpcPlayerPresenceClient { - val channelBuilder = ManagedChannelBuilder.forTarget(target) - channelBuilder.usePlaintext() - // service-player rejects tokenless calls with UNAUTHENTICATED, and every failure here - // is - // swallowed into "unknown" — so without this the whole presence chain fails silently. - channelBuilder.intercept(GroundsTokenInterceptor()) - val channel = channelBuilder.build() - val stub = PlayerPresenceServiceGrpc.newBlockingStub(channel) - return GrpcPlayerPresenceClient(channel, stub) - } - - private fun errorLogoutReply(message: String): PlayerLogoutReply = - PlayerLogoutReply.newBuilder().setRemoved(false).setMessage(message).build() - - private fun errorHeartbeatBatchReply(message: String): PlayerHeartbeatBatchReply = - PlayerHeartbeatBatchReply.newBuilder() - .setUpdated(0) - .setMissing(0) - .setSuccess(false) - .setMessage(message) - .build() - - private fun isServiceUnavailable(status: Status.Code): Boolean = - status == Status.Code.UNAVAILABLE || status == Status.Code.DEADLINE_EXCEEDED - - private const val DEFAULT_TIMEOUT_MS = 2000L - } -} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClient.kt new file mode 100644 index 0000000..345e0c3 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClient.kt @@ -0,0 +1,293 @@ +package gg.grounds.player.presence + +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import java.util.UUID +import tools.jackson.databind.JsonNode +import tools.jackson.databind.json.JsonMapper + +/** + * service-player's HTTP API, as this proxy uses it. + * + * Nothing here throws. Every lookup runs on the command path — `/msg`, tab-complete, a join — and + * an exception on Velocity's event loop is worse than not knowing the answer, so a failure resolves + * to the method's documented "unknown" (null, an empty list, false). Login is the exception that + * proves it: it reports *why* it failed, because "the player may not join" and "I could not ask" + * lead the caller to different behaviour. + * + * Calls are synchronous with a short timeout. The presence service is one hop away in the same + * cluster, and the alternative — letting a join wait on an unbounded call — is what the timeout + * exists to prevent. + */ +class HttpPlayerPresenceClient( + baseUrl: String, + private val tokenProvider: () -> String? = WorkloadToken::load, + private val httpClient: HttpClient = + HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build(), +) : AutoCloseable { + + private val baseUrl = normalizeBaseUrl(baseUrl) + private val mapper = JsonMapper.builder().build() + + /** + * Claims the network-wide session for a player. + * + * 409 is the network refusing a second login, which is an answer rather than a failure. A + * timeout or 5xx is [PlayerLoginResult.Unavailable]: the proxy has learned nothing, and letting + * the player in anyway is a decision for the caller to make knowingly. + */ + fun tryLogin( + playerId: UUID, + playerName: String = "", + proxyId: String = "", + region: String = "", + ): PlayerLoginResult { + val body = + mapper.writeValueAsString( + mapOf( + "playerId" to playerId.toString(), + "playerName" to playerName, + "proxyId" to proxyId, + "region" to region, + ) + ) + val response = + send( + request("/v1/players/sessions") + .header("Content-Type", APPLICATION_JSON) + .POST(HttpRequest.BodyPublishers.ofString(body)) + ) ?: return PlayerLoginResult.Unavailable("presence service did not answer") + + return when (response.statusCode()) { + 201 -> PlayerLoginResult.Accepted + 409 -> PlayerLoginResult.AlreadyOnline + 400 -> PlayerLoginResult.Invalid(problemDetail(response.body())) + in 500..599 -> PlayerLoginResult.Unavailable(problemDetail(response.body())) + else -> PlayerLoginResult.Error("unexpected status ${response.statusCode()}") + } + } + + /** + * Releases this proxy's session for a player. + * + * [proxyId] scopes the delete to the session this proxy owns: a logout that raced a + * proxy-to-proxy transfer must not remove the session the next proxy just created. Blank omits + * the scope, which deletes unconditionally. + */ + fun logout(playerId: UUID, proxyId: String = ""): PlayerLogoutResult { + val query = if (proxyId.isBlank()) "" else "?proxyId=${encode(proxyId)}" + val response = + send(request("/v1/players/$playerId/session$query").DELETE()) + ?: return PlayerLogoutResult.Failed("presence service did not answer") + + return when (response.statusCode()) { + 204 -> PlayerLogoutResult.Removed + 404 -> PlayerLogoutResult.NotFound + else -> PlayerLogoutResult.Failed("unexpected status ${response.statusCode()}") + } + } + + /** Keeps every session this proxy holds alive, in one call. */ + fun heartbeatBatch(playerIds: Collection): PlayerHeartbeatResult { + val body = mapper.writeValueAsString(mapOf("playerIds" to playerIds.map(UUID::toString))) + val response = + send( + request("/v1/players/sessions/heartbeats") + .header("Content-Type", APPLICATION_JSON) + .POST(HttpRequest.BodyPublishers.ofString(body)) + ) + ?: return PlayerHeartbeatResult( + success = false, + message = "presence service did not answer", + updated = 0, + missing = playerIds.size, + ) + + if (response.statusCode() != 200) { + return PlayerHeartbeatResult( + success = false, + message = problemDetail(response.body()), + updated = 0, + missing = playerIds.size, + ) + } + val json = parse(response.body()) + return PlayerHeartbeatResult( + success = true, + message = "heartbeat accepted", + updated = json?.get("updated")?.asInt() ?: 0, + missing = json?.get("missing")?.asInt() ?: 0, + ) + } + + /** Who and where a player is, or null when they are not online or we could not ask. */ + fun getSession(playerId: UUID): PlayerSessionInfo? = + readSession("/v1/players/$playerId/session") + + /** The session behind a name, matched case-insensitively. */ + fun resolveName(playerName: String): PlayerSessionInfo? = + readSession("/v1/players/sessions?name=${encode(playerName)}") + + /** Tab-complete candidates. The server caps the count; a blank prefix returns nothing. */ + fun suggestNames(prefix: String, limit: Int): List { + val response = + send( + request("/v1/players/names/suggestions?prefix=${encode(prefix)}&limit=$limit").GET() + ) ?: return emptyList() + if (response.statusCode() != 200) return emptyList() + val names = parse(response.body())?.get("playerNames") ?: return emptyList() + return names.mapNotNull { it.asString() } + } + + /** Records the backend server a player moved to. False means the move was not recorded. */ + fun updateServer(playerId: UUID, serverName: String): Boolean { + val body = mapper.writeValueAsString(mapOf("serverName" to serverName)) + val response = + send( + request("/v1/players/$playerId/session/server") + .header("Content-Type", APPLICATION_JSON) + .PUT(HttpRequest.BodyPublishers.ofString(body)) + ) ?: return false + return response.statusCode() == 204 + } + + /** + * Players per backend server, network-wide. Null means the count is unknown — which is + * deliberately distinct from a count of zero, because "nobody is online" is a number callers + * will render. + */ + fun countPlayersByServer(): ServerPlayerCounts? { + val json = readJson("/v1/players/counts/servers") ?: return null + val servers = + json.get("servers")?.mapNotNull { entry -> + val name = entry.get("serverName")?.asString() ?: return@mapNotNull null + ServerPlayerCount(name, entry.get("players")?.asInt() ?: 0) + } ?: emptyList() + return ServerPlayerCounts(servers, json.get("total")?.asInt() ?: 0) + } + + /** Players per proxy and region, network-wide. Null means unknown, as above. */ + fun countPlayersByProxy(): ProxyPlayerCounts? { + val json = readJson("/v1/players/counts/proxies") ?: return null + val proxies = + json.get("proxies")?.mapNotNull { entry -> + val id = entry.get("proxyId")?.asString() ?: return@mapNotNull null + ProxyPlayerCount( + proxyId = id, + region = entry.get("region")?.asString()?.takeIf(String::isNotEmpty), + players = entry.get("players")?.asInt() ?: 0, + ) + } ?: emptyList() + return ProxyPlayerCounts(proxies, json.get("total")?.asInt() ?: 0) + } + + /** The player's stored language tag, or null when they have chosen none. */ + fun getLocale(playerId: UUID): String? = + readJson("/v1/players/$playerId/locale") + ?.get("locale") + ?.asString() + ?.takeIf(String::isNotEmpty) + + /** Stores, or with a blank tag clears, the player's language. */ + fun setLocale(playerId: UUID, locale: String): Boolean { + val body = mapper.writeValueAsString(mapOf("locale" to locale)) + val response = + send( + request("/v1/players/$playerId/locale") + .header("Content-Type", APPLICATION_JSON) + .PUT(HttpRequest.BodyPublishers.ofString(body)) + ) ?: return false + return response.statusCode() == 204 + } + + override fun close() { + httpClient.close() + } + + private fun readSession(path: String): PlayerSessionInfo? { + val json = readJson(path) ?: return null + val playerId = + json.get("playerId")?.asString()?.let { + runCatching { UUID.fromString(it) }.getOrNull() + } ?: return null + return PlayerSessionInfo( + playerId = playerId, + playerName = json.get("playerName")?.asString()?.takeIf(String::isNotEmpty), + proxyId = json.get("proxyId")?.asString()?.takeIf(String::isNotEmpty), + serverName = json.get("serverName")?.asString()?.takeIf(String::isNotEmpty), + region = json.get("region")?.asString()?.takeIf(String::isNotEmpty), + connectedAtMillis = parseInstantMillis(json.get("connectedAt")?.asString()), + ) + } + + private fun readJson(path: String): JsonNode? { + val response = send(request(path).GET()) ?: return null + if (response.statusCode() != 200) return null + return parse(response.body()) + } + + private fun request(path: String): HttpRequest.Builder { + val builder = + HttpRequest.newBuilder(URI.create("$baseUrl$path")) + .header("Accept", "$APPLICATION_JSON, $PROBLEM_JSON") + .timeout(REQUEST_TIMEOUT) + tokenProvider()?.let { builder.header("Authorization", "Bearer $it") } + return builder + } + + /** Null for anything that stopped the exchange: a timeout, a refused connection, a thread. */ + private fun send(builder: HttpRequest.Builder): HttpResponse? = + try { + httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + null + } catch (_: Exception) { + null + } + + private fun parse(body: String?): JsonNode? = + try { + body?.takeIf(String::isNotBlank)?.let(mapper::readTree) + } catch (_: Exception) { + null + } + + /** The `detail` out of an RFC 9457 body, falling back to something a log can still use. */ + private fun problemDetail(body: String?): String = + parse(body)?.get("detail")?.asString()?.takeIf(String::isNotBlank) + ?: "presence service rejected the request" + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + private fun parseInstantMillis(value: String?): Long = + value?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: 0L + + companion object { + private const val APPLICATION_JSON = "application/json" + private const val PROBLEM_JSON = "application/problem+json" + private val CONNECT_TIMEOUT = Duration.ofSeconds(2) + + /** + * Matches the deadline the gRPC client used, for the same reason: this is on the join path. + */ + private val REQUEST_TIMEOUT = Duration.ofSeconds(2) + + /** + * The deploy sets the service address without a scheme, the way `MATCH_SERVICE_URL` is set. + * Prepending it here keeps that from being a silent `URI.create` failure at the first + * login. + */ + internal fun normalizeBaseUrl(raw: String): String { + val trimmed = raw.trim().trimEnd('/') + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) trimmed + else "http://$trimmed" + } + } +} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt b/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt deleted file mode 100644 index e08adc3..0000000 --- a/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package gg.grounds.player.presence - -import gg.grounds.grpc.player.PlayerLoginReply - -sealed class PlayerLoginResult { - data class Success(val reply: PlayerLoginReply) : PlayerLoginResult() - - data class Unavailable(val message: String) : PlayerLoginResult() - - data class Error(val message: String) : PlayerLoginResult() -} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/PresenceModels.kt b/common/src/main/kotlin/gg/grounds/player/presence/PresenceModels.kt new file mode 100644 index 0000000..cec9ddf --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/PresenceModels.kt @@ -0,0 +1,74 @@ +package gg.grounds.player.presence + +import java.util.UUID + +/** + * A player's live session, as service-player knows it. + * + * Everything but the id may be absent: a session created by an older proxy carries no name, a + * player who has not reached a backend yet is on no server, and a proxy that declares no region + * leaves that unknown. Absent is a normal answer here rather than a fault, so these are nullable + * rather than empty strings. + */ +data class PlayerSessionInfo( + val playerId: UUID, + val playerName: String?, + val proxyId: String?, + val serverName: String?, + val region: String?, + val connectedAtMillis: Long, +) + +/** + * What came of claiming a session. + * + * [AlreadyOnline] is a normal answer, not a failure — it is how the network refuses a second login. + * [Unavailable] and [Error] are: the proxy could not ask, so it knows nothing about whether the + * player may join. + */ +sealed class PlayerLoginResult { + data object Accepted : PlayerLoginResult() + + data object AlreadyOnline : PlayerLoginResult() + + /** The service rejected the request itself. A bug on this side, not a transient condition. */ + data class Invalid(val message: String) : PlayerLoginResult() + + data class Unavailable(val message: String) : PlayerLoginResult() + + data class Error(val message: String) : PlayerLoginResult() +} + +/** What came of releasing a session. Only [Removed] means this proxy actually held one. */ +sealed class PlayerLogoutResult { + data object Removed : PlayerLogoutResult() + + /** Already gone, expired, or taken over by another proxy. Nothing to do about any of them. */ + data object NotFound : PlayerLogoutResult() + + data class Failed(val message: String) : PlayerLogoutResult() +} + +/** + * What a heartbeat batch did. [missing] counts players with no session, which is normal in small + * numbers — someone logged out between building the batch and the write landing. + */ +data class PlayerHeartbeatResult( + val success: Boolean, + val message: String, + val updated: Int, + val missing: Int, +) + +data class ServerPlayerCount(val serverName: String, val players: Int) + +/** + * Players per backend server, network-wide. Only occupied servers appear; [total] can exceed their + * sum because it counts players who have not reached a backend yet. + */ +data class ServerPlayerCounts(val servers: List, val total: Int) + +data class ProxyPlayerCount(val proxyId: String, val region: String?, val players: Int) + +/** Players per proxy, network-wide. Only occupied proxies appear; [total] is their sum. */ +data class ProxyPlayerCounts(val proxies: List, val total: Int) diff --git a/common/src/main/kotlin/gg/grounds/player/presence/WorkloadToken.kt b/common/src/main/kotlin/gg/grounds/player/presence/WorkloadToken.kt new file mode 100644 index 0000000..f369479 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/WorkloadToken.kt @@ -0,0 +1,30 @@ +package gg.grounds.player.presence + +import java.nio.file.Files +import java.nio.file.Path + +/** + * The projected ServiceAccount token this proxy presents to service-player. + * + * The Grounds charts project a short-lived token (audience `grounds-services`) into the proxy pod + * and point [TOKEN_FILE_ENV] at it. kubelet rotates the file, so it is read per request rather than + * held in a field — a cached token expires mid-shift, and a credential in a field ends up in some + * `toString()` sooner or later. + * + * With no token file present — local dev against a service running `grounds.auth.enabled=false` — + * this returns null and the request goes out unauthenticated. + */ +object WorkloadToken { + const val TOKEN_FILE_ENV: String = "GROUNDS_TOKEN_FILE" + const val DEFAULT_TOKEN_PATH: String = "/var/run/secrets/grounds/token" + + fun load(): String? = loadFrom(System.getenv(TOKEN_FILE_ENV) ?: DEFAULT_TOKEN_PATH) + + internal fun loadFrom(path: String): String? = + try { + val file = Path.of(path) + if (Files.exists(file)) Files.readString(file).trim().ifEmpty { null } else null + } catch (_: Exception) { + null + } +} diff --git a/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt b/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt deleted file mode 100644 index 855bfe2..0000000 --- a/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package gg.grounds.player.presence - -import io.grpc.CallOptions -import io.grpc.Channel -import io.grpc.ClientCall -import io.grpc.Metadata -import io.grpc.MethodDescriptor -import java.io.InputStream -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Test - -class GroundsTokenInterceptorTest { - - @Test - fun attachesBearerTokenWhenTokenIsAvailable() { - val channel = CapturingChannel() - - GroundsTokenInterceptor(tokenLoader = { "jwt-abc" }) - .interceptCall(METHOD, CallOptions.DEFAULT, channel) - .start(NoopListener(), Metadata()) - - assertEquals("Bearer jwt-abc", channel.capturedHeaders?.get(AUTHORIZATION)) - } - - @Test - fun sendsNoAuthorizationHeaderWhenTokenIsMissing() { - val channel = CapturingChannel() - - GroundsTokenInterceptor(tokenLoader = { null }) - .interceptCall(METHOD, CallOptions.DEFAULT, channel) - .start(NoopListener(), Metadata()) - - assertFalse(channel.capturedHeaders?.containsKey(AUTHORIZATION) ?: true) - } - - private class CapturingChannel : Channel() { - var capturedHeaders: Metadata? = null - - override fun authority(): String = "test" - - override fun newCall( - methodDescriptor: MethodDescriptor, - callOptions: CallOptions, - ): ClientCall = - object : ClientCall() { - override fun start(responseListener: Listener, headers: Metadata) { - capturedHeaders = headers - } - - override fun request(numMessages: Int) = Unit - - override fun cancel(message: String?, cause: Throwable?) = Unit - - override fun halfClose() = Unit - - override fun sendMessage(message: ReqT) = Unit - } - } - - private class NoopListener : ClientCall.Listener() - - private companion object { - val AUTHORIZATION: Metadata.Key = - Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) - - val MARSHALLER = - object : MethodDescriptor.Marshaller { - override fun stream(value: String): InputStream = value.byteInputStream() - - override fun parse(stream: InputStream): String = stream.reader().readText() - } - - val METHOD: MethodDescriptor = - MethodDescriptor.newBuilder(MARSHALLER, MARSHALLER) - .setType(MethodDescriptor.MethodType.UNARY) - .setFullMethodName("gg.grounds.player.Test/Call") - .build() - } -} diff --git a/common/src/test/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClientTest.kt b/common/src/test/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClientTest.kt new file mode 100644 index 0000000..a516827 --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/player/presence/HttpPlayerPresenceClientTest.kt @@ -0,0 +1,248 @@ +package gg.grounds.player.presence + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class HttpPlayerPresenceClientTest { + + private lateinit var server: HttpServer + private lateinit var client: HttpPlayerPresenceClient + + /** Path -> what to answer with. Recorded requests land in [seen]. */ + private val routes = ConcurrentHashMap>() + private val seen = ConcurrentHashMap() + + data class RecordedRequest(val method: String, val query: String?, val authorization: String?) + + @BeforeEach + fun startServer() { + server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + server.createContext("/") { exchange -> answer(exchange) } + server.start() + client = + HttpPlayerPresenceClient( + baseUrl = "127.0.0.1:${server.address.port}", + tokenProvider = { "test-token" }, + ) + } + + @AfterEach + fun stopServer() { + client.close() + server.stop(0) + } + + private fun answer(exchange: HttpExchange) { + val path = exchange.requestURI.path + seen[path] = + RecordedRequest( + method = exchange.requestMethod, + query = exchange.requestURI.query, + authorization = exchange.requestHeaders.getFirst("Authorization"), + ) + val (status, body) = routes[path] ?: (404 to "") + val bytes = body.toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + // A 204 must not carry a body, and the JDK server enforces it. + if (status == 204 || bytes.isEmpty()) { + exchange.sendResponseHeaders(status, -1) + } else { + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + exchange.close() + } + + @Test + fun `a created session is accepted, and the workload token rides along`() { + routes["/v1/players/sessions"] = 201 to "" + + val result = client.tryLogin(PLAYER_ID, "Notch", "velocity-1", "nl-ams1") + + assertEquals(PlayerLoginResult.Accepted, result) + assertEquals("Bearer test-token", seen["/v1/players/sessions"]?.authorization) + } + + @Test + fun `409 is already-online, not an error`() { + routes["/v1/players/sessions"] = + 409 to """{"title":"Player already online","status":409,"code":"already_online"}""" + + assertEquals(PlayerLoginResult.AlreadyOnline, client.tryLogin(PLAYER_ID)) + } + + @Test + fun `a 5xx is unavailable, so the caller knows it learned nothing`() { + routes["/v1/players/sessions"] = + 503 to + """{"title":"Service unavailable","status":503,"detail":"store is down","code":"store_unavailable"}""" + + val result = client.tryLogin(PLAYER_ID) + + assertTrue(result is PlayerLoginResult.Unavailable, "expected Unavailable, got $result") + assertEquals("store is down", (result as PlayerLoginResult.Unavailable).message) + } + + @Test + fun `an unreachable service is unavailable rather than an exception`() { + client.close() + val dead = HttpPlayerPresenceClient("127.0.0.1:1", tokenProvider = { null }) + + assertTrue(dead.tryLogin(PLAYER_ID) is PlayerLoginResult.Unavailable) + assertNull(dead.getSession(PLAYER_ID)) + assertEquals(emptyList(), dead.suggestNames("no", 5)) + assertNull(dead.countPlayersByServer()) + dead.close() + } + + @Test + fun `a session is read with its absent fields left absent`() { + routes["/v1/players/$PLAYER_ID/session"] = + 200 to + """{"playerId":"$PLAYER_ID","playerName":"Notch","proxyId":"velocity-1", + |"serverName":null,"region":null,"connectedAt":"2026-08-04T10:00:00Z"}""" + .trimMargin() + + val session = client.getSession(PLAYER_ID) + + assertEquals("Notch", session?.playerName) + assertEquals("velocity-1", session?.proxyId) + assertNull(session?.serverName) + assertNull(session?.region) + assertEquals(1785837600000L, session?.connectedAtMillis) + } + + @Test + fun `a player who is not online reads as null`() { + routes["/v1/players/$PLAYER_ID/session"] = 404 to """{"code":"not_found"}""" + + assertNull(client.getSession(PLAYER_ID)) + } + + @Test + fun `logout scopes the delete to the calling proxy`() { + routes["/v1/players/$PLAYER_ID/session"] = 204 to "" + + assertEquals(PlayerLogoutResult.Removed, client.logout(PLAYER_ID, "velocity-1")) + val request = seen["/v1/players/$PLAYER_ID/session"] + assertEquals("DELETE", request?.method) + assertEquals("proxyId=velocity-1", request?.query) + } + + @Test + fun `logout without a proxy sends no scope`() { + routes["/v1/players/$PLAYER_ID/session"] = 404 to "" + + assertEquals(PlayerLogoutResult.NotFound, client.logout(PLAYER_ID)) + assertNull(seen["/v1/players/$PLAYER_ID/session"]?.query) + } + + @Test + fun `a heartbeat batch reports what it touched`() { + routes["/v1/players/sessions/heartbeats"] = 200 to """{"updated":3,"missing":1}""" + + val result = client.heartbeatBatch(listOf(PLAYER_ID, UUID.randomUUID())) + + assertTrue(result.success) + assertEquals(3, result.updated) + assertEquals(1, result.missing) + } + + @Test + fun `a rejected heartbeat batch counts every player as missing`() { + routes["/v1/players/sessions/heartbeats"] = + 400 to """{"detail":"playerIds must be UUIDs","code":"invalid_request"}""" + + val result = client.heartbeatBatch(listOf(PLAYER_ID, UUID.randomUUID())) + + assertEquals(false, result.success) + assertEquals(0, result.updated) + assertEquals(2, result.missing) + assertEquals("playerIds must be UUIDs", result.message) + } + + @Test + fun `a name resolves through the session collection`() { + routes["/v1/players/sessions"] = + 200 to + """{"playerId":"$PLAYER_ID","playerName":"Notch","proxyId":null,"serverName":null, + |"region":null,"connectedAt":"2026-08-04T10:00:00Z"}""" + .trimMargin() + + assertEquals(PLAYER_ID, client.resolveName("notch")?.playerId) + assertEquals("name=notch", seen["/v1/players/sessions"]?.query) + } + + @Test + fun `suggestions come back as a plain list`() { + routes["/v1/players/names/suggestions"] = 200 to """{"playerNames":["Notch","Nobody"]}""" + + assertEquals(listOf("Notch", "Nobody"), client.suggestNames("no", 10)) + } + + @Test + fun `counts carry the total and drop an absent region`() { + routes["/v1/players/counts/proxies"] = + 200 to + """ + |{"proxies":[{"proxyId":"velocity-1","region":"nl-ams1","players":3}, + |{"proxyId":"velocity-2","region":null,"players":1}],"total":4} + """ + .trimMargin() + + val counts = client.countPlayersByProxy() + + assertEquals(4, counts?.total) + assertEquals("nl-ams1", counts?.proxies?.get(0)?.region) + assertNull(counts?.proxies?.get(1)?.region) + } + + @Test + fun `an unset locale reads as null`() { + routes["/v1/players/$PLAYER_ID/locale"] = 200 to """{"locale":null}""" + + assertNull(client.getLocale(PLAYER_ID)) + } + + @Test + fun `storing a locale reports whether it landed`() { + routes["/v1/players/$PLAYER_ID/locale"] = 204 to "" + + assertTrue(client.setLocale(PLAYER_ID, "de-DE")) + assertEquals("PUT", seen["/v1/players/$PLAYER_ID/locale"]?.method) + } + + @Test + fun `a server move that matched no session is false`() { + routes["/v1/players/$PLAYER_ID/session/server"] = 404 to """{"code":"not_found"}""" + + assertEquals(false, client.updateServer(PLAYER_ID, "lobby-2")) + } + + @Test + fun `a base url without a scheme is still usable`() { + assertEquals( + "http://service-player.api.svc.cluster.local:9000", + HttpPlayerPresenceClient.normalizeBaseUrl("service-player.api.svc.cluster.local:9000/"), + ) + assertEquals( + "https://player.example", + HttpPlayerPresenceClient.normalizeBaseUrl(" https://player.example/ "), + ) + } + + companion object { + private val PLAYER_ID: UUID = UUID.fromString("8f3a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b") + } +} diff --git a/common/src/test/kotlin/gg/grounds/player/presence/WorkloadTokenTest.kt b/common/src/test/kotlin/gg/grounds/player/presence/WorkloadTokenTest.kt new file mode 100644 index 0000000..be43884 --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/player/presence/WorkloadTokenTest.kt @@ -0,0 +1,32 @@ +package gg.grounds.player.presence + +import java.nio.file.Files +import kotlin.io.path.writeText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class WorkloadTokenTest { + + @Test + fun `a projected token is read and trimmed`() { + val file = Files.createTempFile("grounds-token", "") + file.writeText(" a.projected.jwt\n") + + assertEquals("a.projected.jwt", WorkloadToken.loadFrom(file.toString())) + } + + @Test + fun `an empty token file is the same as none`() { + val file = Files.createTempFile("grounds-token", "") + file.writeText(" \n") + + assertNull(WorkloadToken.loadFrom(file.toString())) + } + + /** Local development against a service with auth disabled: the request goes out bare. */ + @Test + fun `a missing token file is not an error`() { + assertNull(WorkloadToken.loadFrom("/path/that/does/not/exist/token")) + } +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 207891c..79b35f5 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -17,7 +17,6 @@ dependencies { compileOnly("gg.grounds:plugin-proxy-api:0.5.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.4") implementation("tools.jackson.module:jackson-module-kotlin:3.0.4") - implementation("io.grpc:grpc-netty-shaded:1.78.0") // compileOnly above is not visible to tests; PlayerSessionQueryImplTest needs the interface's // types. diff --git a/velocity/devspace.yaml b/velocity/devspace.yaml index f4dd501..0be66e2 100644 --- a/velocity/devspace.yaml +++ b/velocity/devspace.yaml @@ -30,7 +30,7 @@ dev: disableReplace: true command: | # ENV needs to be exported in start command because we are not letting devspace replace the pod - export PLAYER_PRESENCE_GRPC_TARGET=dns:///service-player.api.svc.cluster.local:9000 + export PLAYER_SERVICE_URL=http://service-player.api.svc.cluster.local:9000 echo "Use ./start.sh to start the server" /bin/sh # Forward the following ports to be able to access your application via localhost diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index 77c9e4c..8de73ae 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -23,10 +23,6 @@ import gg.grounds.presence.PlayerSessionQueryImpl import gg.grounds.proxy.api.PlayerLocaleQuery import gg.grounds.proxy.api.PlayerSessionQuery import gg.grounds.proxy.api.ProxyServiceRegistry -import io.grpc.LoadBalancerRegistry -import io.grpc.NameResolverRegistry -import io.grpc.internal.DnsNameResolverProvider -import io.grpc.internal.PickFirstLoadBalancerProvider import java.nio.file.Path import org.slf4j.Logger @@ -58,11 +54,9 @@ constructor( @Subscribe fun onInitialize(event: ProxyInitializeEvent) { - registerProviders() - val messages = MessagesConfigLoader(logger, dataDirectory).loadOrCreate() - val target = resolveTarget() - playerPresenceService.configure(target) + val serviceUrl = resolveServiceUrl() + playerPresenceService.configure(serviceUrl) proxy.eventManager.register( this, @@ -100,7 +94,7 @@ constructor( registerLinkCommands(messages) heartbeatScheduler.start() - logger.info("Configured player presence gRPC client (target={})", target) + logger.info("Configured player presence client (serviceUrl={})", serviceUrl) } /** @@ -140,18 +134,11 @@ constructor( } /** - * Registers gRPC name resolver and load balancer providers so client channels can resolve DNS - * targets and select endpoints when running inside Velocity's shaded environment. This manual - * step avoids startup IllegalArgumentExceptions caused by shaded classes not being discoverable - * via the default provider lookup. + * Where service-player answers. Required: a proxy that cannot reach presence cannot decide + * whether a player may join, and failing at startup says so louder than every login failing. */ - private fun registerProviders() { - NameResolverRegistry.getDefaultRegistry().register(DnsNameResolverProvider()) - LoadBalancerRegistry.getDefaultRegistry().register(PickFirstLoadBalancerProvider()) - } - - private fun resolveTarget(): String { - return System.getenv("PLAYER_PRESENCE_GRPC_TARGET")?.takeIf { it.isNotBlank() } - ?: error("Missing required environment variable PLAYER_PRESENCE_GRPC_TARGET") + private fun resolveServiceUrl(): String { + return System.getenv("PLAYER_SERVICE_URL")?.takeIf { it.isNotBlank() } + ?: error("Missing required environment variable PLAYER_SERVICE_URL") } } diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt index b01c63e..7643c80 100644 --- a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt @@ -9,9 +9,8 @@ import com.velocitypowered.api.event.player.ServerConnectedEvent import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.util.UuidUtils import gg.grounds.config.MessagesConfig -import gg.grounds.grpc.player.LoginStatus -import gg.grounds.grpc.player.PlayerLoginReply import gg.grounds.player.presence.PlayerLoginResult +import gg.grounds.player.presence.PlayerLogoutResult import gg.grounds.presence.PlayerPresenceService import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -49,10 +48,26 @@ class PlayerConnectionListener( when ( val result = playerPresenceService.tryLogin(playerId, name, proxyId(), region()) ) { - is PlayerLoginResult.Success -> { - if (handleSuccess(event, name, playerId, result.reply)) { - return@async - } + PlayerLoginResult.Accepted -> { + logger.info("Player session created (playerId={}, username={})", playerId, name) + watchForAbandonedLogin(playerId, name) + } + PlayerLoginResult.AlreadyOnline -> { + logger.warn( + "Player session rejected (playerId={}, username={}, reason=already_online)", + playerId, + name, + ) + deny(event, messages.alreadyOnline) + } + is PlayerLoginResult.Invalid -> { + logger.warn( + "Player session rejected (playerId={}, username={}, reason={})", + playerId, + name, + result.message, + ) + deny(event, messages.invalidRequest) } is PlayerLoginResult.Unavailable -> { logger.warn( @@ -89,60 +104,30 @@ class PlayerConnectionListener( val name = event.player.username return EventTask.async { - val result = playerPresenceService.logout(playerId, proxyId()) ?: return@async - if (result.removed) { - logger.info( - "Player session logout completed (playerId={}, username={}, message={})", - playerId, - name, - result.message, - ) - } else { - logger.warn( - "Player session logout failed (playerId={}, username={}, message={})", - playerId, - name, - result.message, - ) - } - } - } - - private fun handleSuccess( - event: PreLoginEvent, - name: String, - playerId: UUID, - reply: PlayerLoginReply, - ): Boolean { - val kickMessage = - when (reply.status) { - LoginStatus.LOGIN_STATUS_ACCEPTED -> { + when (val result = playerPresenceService.logout(playerId, proxyId())) { + PlayerLogoutResult.Removed -> logger.info( - "Player session created (playerId={}, username={}, status={})", + "Player session logout completed (playerId={}, username={})", playerId, name, - reply.status, ) - watchForAbandonedLogin(playerId, name) - return true - } - LoginStatus.LOGIN_STATUS_ALREADY_ONLINE -> messages.alreadyOnline - LoginStatus.LOGIN_STATUS_INVALID_REQUEST -> messages.invalidRequest - LoginStatus.LOGIN_STATUS_UNSPECIFIED, - LoginStatus.LOGIN_STATUS_ERROR, - LoginStatus.UNRECOGNIZED -> messages.genericError + // The session was already gone, expired, or taken over by the proxy the player + // moved to. None of those are this proxy's problem to fix. + PlayerLogoutResult.NotFound -> + logger.debug( + "Player session logout found nothing to remove (playerId={}, username={})", + playerId, + name, + ) + is PlayerLogoutResult.Failed -> + logger.warn( + "Player session logout failed (playerId={}, username={}, message={})", + playerId, + name, + result.message, + ) } - - logger.warn( - "Player session rejected (playerId={}, username={}, status={}, message={})", - playerId, - name, - reply.status, - reply.message, - ) - - deny(event, kickMessage) - return false + } } private fun deny(event: PreLoginEvent, message: String) { @@ -186,7 +171,7 @@ class PlayerConnectionListener( "Player session released after abandoned login (playerId={}, username={}, removed={})", playerId, name, - result?.removed, + result == PlayerLogoutResult.Removed, ) } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index 550ca17..4569fea 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -1,26 +1,27 @@ package gg.grounds.presence -import gg.grounds.grpc.player.CountPlayersByProxyReply -import gg.grounds.grpc.player.CountPlayersByServerReply -import gg.grounds.grpc.player.PlayerLogoutReply -import gg.grounds.grpc.player.PlayerSessionInfo -import gg.grounds.player.presence.GrpcPlayerPresenceClient +import gg.grounds.player.presence.HttpPlayerPresenceClient +import gg.grounds.player.presence.PlayerHeartbeatResult import gg.grounds.player.presence.PlayerLoginResult +import gg.grounds.player.presence.PlayerLogoutResult +import gg.grounds.player.presence.PlayerSessionInfo +import gg.grounds.player.presence.ProxyPlayerCounts +import gg.grounds.player.presence.ServerPlayerCounts import java.util.UUID +/** + * The proxy's handle on service-player. + * + * Thin by design: the client already resolves every failure into the answer the caller can act on, + * so this exists to own the client's lifecycle and to keep the rest of the plugin from importing + * the transport. + */ class PlayerPresenceService : AutoCloseable { - private lateinit var client: GrpcPlayerPresenceClient + private var client: HttpPlayerPresenceClient? = null - data class HeartbeatBatchResult( - val success: Boolean, - val message: String, - val updated: Int, - val missing: Int, - ) - - fun configure(target: String) { + fun configure(baseUrl: String) { close() - client = GrpcPlayerPresenceClient.create(target) + client = HttpPlayerPresenceClient(baseUrl) } fun tryLogin( @@ -28,109 +29,67 @@ class PlayerPresenceService : AutoCloseable { playerName: String, proxyId: String, region: String, - ): PlayerLoginResult { - return try { - client.tryLogin(playerId, playerName, proxyId, region) - } catch (e: RuntimeException) { - PlayerLoginResult.Error(e.message ?: e::class.java.name) + ): PlayerLoginResult = + withClient(PlayerLoginResult.Unavailable("presence service is not configured")) { + it.tryLogin(playerId, playerName, proxyId, region) } - } - /** - * Cross-proxy lookups. Never throw: a failure means "unknown", and the caller falls back to - * local. - */ - fun getSession(playerId: UUID): PlayerSessionInfo? { - return try { - client.getSession(playerId) - } catch (e: RuntimeException) { - null + fun logout(playerId: UUID, proxyId: String = ""): PlayerLogoutResult = + withClient(PlayerLogoutResult.Failed("presence service is not configured")) { + it.logout(playerId, proxyId) } - } - fun resolveName(playerName: String): PlayerSessionInfo? { - return try { - client.resolveName(playerName) - } catch (e: RuntimeException) { - null + fun heartbeatBatch(playerIds: Collection): PlayerHeartbeatResult = + withClient( + PlayerHeartbeatResult( + success = false, + message = "presence service is not configured", + updated = 0, + missing = playerIds.size, + ) + ) { + it.heartbeatBatch(playerIds) } - } - fun suggestNames(prefix: String, limit: Int): List { - return try { - client.suggestNames(prefix, limit) - } catch (e: RuntimeException) { - emptyList() - } - } + /** Cross-proxy lookups. Null or empty means "unknown"; the caller falls back to local. */ + fun getSession(playerId: UUID): PlayerSessionInfo? = + withClient(null) { it.getSession(playerId) } - fun countPlayersByServer(): CountPlayersByServerReply? { - return try { - client.countPlayersByServer() - } catch (e: RuntimeException) { - null - } - } + fun resolveName(playerName: String): PlayerSessionInfo? = + withClient(null) { it.resolveName(playerName) } - fun countPlayersByProxy(): CountPlayersByProxyReply? { - return try { - client.countPlayersByProxy() - } catch (e: RuntimeException) { - null - } - } + fun suggestNames(prefix: String, limit: Int): List = + withClient(emptyList()) { it.suggestNames(prefix, limit) } - fun updateServer(playerId: UUID, serverName: String): Boolean { - return try { - client.updateServer(playerId, serverName) - } catch (e: RuntimeException) { - false - } - } + fun countPlayersByServer(): ServerPlayerCounts? = withClient(null) { it.countPlayersByServer() } - fun logout(playerId: UUID, proxyId: String = ""): PlayerLogoutReply? { - return try { - client.logout(playerId, proxyId) - } catch (e: RuntimeException) { - null - } - } + fun countPlayersByProxy(): ProxyPlayerCounts? = withClient(null) { it.countPlayersByProxy() } - fun heartbeatBatch(playerIds: Collection): HeartbeatBatchResult { - return try { - val reply = client.heartbeatBatch(playerIds) - HeartbeatBatchResult(reply.success, reply.message, reply.updated, reply.missing) - } catch (e: RuntimeException) { - HeartbeatBatchResult( - success = false, - message = e.message ?: e::class.java.name, - updated = 0, - missing = playerIds.size, - ) - } - } + fun updateServer(playerId: UUID, serverName: String): Boolean = + withClient(false) { it.updateServer(playerId, serverName) } - /** The player's stored language tag, or null when none is set. Never throws. */ - fun getLocale(playerId: UUID): String? { - return try { - client.getLocale(playerId) - } catch (e: RuntimeException) { - null - } - } + /** The player's stored language tag, or null when none is set. */ + fun getLocale(playerId: UUID): String? = withClient(null) { it.getLocale(playerId) } + + /** Persists, or with a blank tag clears, the player's language. */ + fun setLocale(playerId: UUID, locale: String): Boolean = + withClient(false) { it.setLocale(playerId, locale) } - /** Persists (or clears, with a blank tag) the player's language. Never throws. */ - fun setLocale(playerId: UUID, locale: String): Boolean { + /** + * An unconfigured service answers like an unreachable one. A plugin that half-loaded should + * degrade the way the network already knows how to handle, not throw into an event listener. + */ + private fun withClient(fallback: T, call: (HttpPlayerPresenceClient) -> T): T { + val current = client ?: return fallback return try { - client.setLocale(playerId, locale) - } catch (e: RuntimeException) { - false + call(current) + } catch (_: RuntimeException) { + fallback } } override fun close() { - if (this::client.isInitialized) { - client.close() - } + client?.close() + client = null } } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt index 6b41011..08ac7b9 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt @@ -1,7 +1,8 @@ package gg.grounds.presence -import gg.grounds.grpc.player.CountPlayersByProxyReply -import gg.grounds.grpc.player.CountPlayersByServerReply +import gg.grounds.player.presence.PlayerSessionInfo as PresenceSessionInfo +import gg.grounds.player.presence.ProxyPlayerCounts +import gg.grounds.player.presence.ServerPlayerCounts import gg.grounds.proxy.api.NetworkPlayerCounts import gg.grounds.proxy.api.NetworkProxyCounts import gg.grounds.proxy.api.PlayerSessionInfo @@ -34,43 +35,36 @@ class PlayerSessionQueryImpl(private val presenceService: PlayerPresenceService) override fun countPlayersByProxy(): NetworkProxyCounts? = presenceService.countPlayersByProxy()?.let(::toNetworkProxyCounts) - /** - * A session with no usable id or name tells the caller nothing — drop it rather than - * half-answer. - */ - private fun toInfo(session: gg.grounds.grpc.player.PlayerSessionInfo): PlayerSessionInfo? { - val playerId = runCatching { UUID.fromString(session.playerId) }.getOrNull() ?: return null - val name = session.playerName.takeIf { it.isNotEmpty() } ?: return null + /** A session with no usable name tells the caller nothing — drop it rather than half-answer. */ + private fun toInfo(session: PresenceSessionInfo): PlayerSessionInfo? { + val name = session.playerName ?: return null return PlayerSessionInfo( - playerId = playerId, + playerId = session.playerId, name = name, - proxyId = session.proxyId.takeIf { it.isNotEmpty() }, - server = session.serverName.takeIf { it.isNotEmpty() }, + proxyId = session.proxyId, + server = session.serverName, connectedAt = session.connectedAtMillis, - region = session.region.takeIf { it.isNotEmpty() }, + region = session.region, ) } /** - * `servers` has one row per occupied backend server — a server nobody is on is absent, not a - * zero entry. - */ - /** - * `proxies` has one row per occupied proxy. An empty region string means the proxy declares - * none — mapped to null rather than kept as "", so callers have one shape for "unknown". + * `proxies` has one row per occupied proxy; a proxy that declares no region is already null + * here rather than "", so callers have one shape for "unknown". */ - internal fun toNetworkProxyCounts(reply: CountPlayersByProxyReply): NetworkProxyCounts = + internal fun toNetworkProxyCounts(counts: ProxyPlayerCounts): NetworkProxyCounts = NetworkProxyCounts( - proxies = - reply.proxiesList.map { - ProxyPlayers(it.proxyId, it.region.takeIf(String::isNotEmpty), it.players) - }, - total = reply.total, + proxies = counts.proxies.map { ProxyPlayers(it.proxyId, it.region, it.players) }, + total = counts.total, ) - internal fun toNetworkPlayerCounts(reply: CountPlayersByServerReply): NetworkPlayerCounts = + /** + * `servers` has one row per occupied backend server — a server nobody is on is absent, not a + * zero entry. + */ + internal fun toNetworkPlayerCounts(counts: ServerPlayerCounts): NetworkPlayerCounts = NetworkPlayerCounts( - byServer = reply.serversList.associate { it.serverName to it.players }, - total = reply.total, + byServer = counts.servers.associate { it.serverName to it.players }, + total = counts.total, ) } diff --git a/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt b/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt index 28d9e57..91f1c8a 100644 --- a/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt @@ -1,7 +1,9 @@ package gg.grounds.presence -import gg.grounds.grpc.player.CountPlayersByServerReply -import gg.grounds.grpc.player.ServerPlayerCount +import gg.grounds.player.presence.ProxyPlayerCount +import gg.grounds.player.presence.ProxyPlayerCounts +import gg.grounds.player.presence.ServerPlayerCount +import gg.grounds.player.presence.ServerPlayerCounts import java.net.ServerSocket import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -11,21 +13,35 @@ class PlayerSessionQueryImplTest { @Test fun countPlayersByServerMapsServersAndCarriesTotal() { - val reply = - CountPlayersByServerReply.newBuilder() - .addServers( - ServerPlayerCount.newBuilder().setServerName("lobby-1").setPlayers(2).build() - ) - .addServers( - ServerPlayerCount.newBuilder().setServerName("lobby-2").setPlayers(5).build() - ) - .setTotal(8) - .build() - - val counts = PlayerSessionQueryImpl(PlayerPresenceService()).toNetworkPlayerCounts(reply) - - assertEquals(mapOf("lobby-1" to 2, "lobby-2" to 5), counts.byServer) - assertEquals(8, counts.total) + val counts = + ServerPlayerCounts( + servers = listOf(ServerPlayerCount("lobby-1", 2), ServerPlayerCount("lobby-2", 5)), + total = 8, + ) + + val mapped = PlayerSessionQueryImpl(PlayerPresenceService()).toNetworkPlayerCounts(counts) + + assertEquals(mapOf("lobby-1" to 2, "lobby-2" to 5), mapped.byServer) + assertEquals(8, mapped.total) + } + + @Test + fun countPlayersByProxyKeepsAnAbsentRegionAbsent() { + val counts = + ProxyPlayerCounts( + proxies = + listOf( + ProxyPlayerCount("velocity-1", "nl-ams1", 3), + ProxyPlayerCount("velocity-2", null, 1), + ), + total = 4, + ) + + val mapped = PlayerSessionQueryImpl(PlayerPresenceService()).toNetworkProxyCounts(counts) + + assertEquals("nl-ams1", mapped.proxies[0].region) + assertNull(mapped.proxies[1].region) + assertEquals(4, mapped.total) } @Test