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