From 4363c0e6408c30f0455eae6af2b02dfeb7eb0c03 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Tue, 14 Jul 2026 16:33:32 +0200 Subject: [PATCH] feat(presence): count players by server across the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proxy only knows the players connected to itself, so /agones reported "1 player" in a lobby that actually held two — the second player was on the other proxy. service-player holds the network-wide truth. Add countPlayersByServer() through the presence chain (gRPC client -> PlayerPresenceService -> PlayerSessionQueryImpl) so ProxyService can answer for the whole network instead of just this proxy. A failed or unavailable call returns null, never an empty result: empty means "nobody is online anywhere", null means "we could not ask", and callers must not conflate the two. Bumps library-grpc-contracts-player to 0.4.0 (adds the RPC) and plugin-proxy-api to 0.2.0 (adds the interface method + NetworkPlayerCounts). --- common/build.gradle.kts | 2 +- .../presence/GrpcPlayerPresenceClient.kt | 12 ++++++ velocity/build.gradle.kts | 5 ++- .../grounds/presence/PlayerPresenceService.kt | 9 ++++ .../presence/PlayerSessionQueryImpl.kt | 15 +++++++ .../presence/PlayerSessionQueryImplTest.kt | 43 +++++++++++++++++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 818d935..72e0b6a 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -11,7 +11,7 @@ repositories { } dependencies { - protobuf("gg.grounds:library-grpc-contracts-player:0.3.0") + protobuf("gg.grounds:library-grpc-contracts-player:0.4.0") 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/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index 0a47337..140a494 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -1,5 +1,7 @@ package gg.grounds.player.presence +import gg.grounds.grpc.player.CountPlayersByServerReply +import gg.grounds.grpc.player.CountPlayersByServerRequest import gg.grounds.grpc.player.GetPlayerSessionRequest import gg.grounds.grpc.player.PlayerHeartbeatBatchReply import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest @@ -124,6 +126,16 @@ private constructor( } } + fun countPlayersByServer(): CountPlayersByServerReply? { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .countPlayersByServer(CountPlayersByServerRequest.newBuilder().build()) + } catch (e: RuntimeException) { + null + } + } + fun updateServer(playerId: UUID, serverName: String): Boolean { return try { stub diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 9a284cd..b2c1bda 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,11 +14,14 @@ dependencies { implementation(project(":common")) // plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the // registry this plugin writes into would be a different class from the one chat/social read. - compileOnly("gg.grounds:plugin-proxy-api:0.1.0") + compileOnly("gg.grounds:plugin-proxy-api:0.2.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. + testImplementation("gg.grounds:plugin-proxy-api:0.2.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4") diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index 543f96d..9b4520a 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -1,5 +1,6 @@ package gg.grounds.presence +import gg.grounds.grpc.player.CountPlayersByServerReply import gg.grounds.grpc.player.PlayerLogoutReply import gg.grounds.grpc.player.PlayerSessionInfo import gg.grounds.player.presence.GrpcPlayerPresenceClient @@ -57,6 +58,14 @@ class PlayerPresenceService : AutoCloseable { } } + fun countPlayersByServer(): CountPlayersByServerReply? { + return try { + client.countPlayersByServer() + } catch (e: RuntimeException) { + null + } + } + fun updateServer(playerId: UUID, serverName: String): Boolean { return try { client.updateServer(playerId, serverName) diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt index 9dd3483..68a9d49 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt @@ -1,5 +1,7 @@ package gg.grounds.presence +import gg.grounds.grpc.player.CountPlayersByServerReply +import gg.grounds.proxy.api.NetworkPlayerCounts import gg.grounds.proxy.api.PlayerSessionInfo import gg.grounds.proxy.api.PlayerSessionQuery import java.util.UUID @@ -23,6 +25,9 @@ class PlayerSessionQueryImpl(private val presenceService: PlayerPresenceService) override fun suggestNames(prefix: String, limit: Int): List = presenceService.suggestNames(prefix, limit) + override fun countPlayersByServer(): NetworkPlayerCounts? = + presenceService.countPlayersByServer()?.let(::toNetworkPlayerCounts) + /** * A session with no usable id or name tells the caller nothing — drop it rather than * half-answer. @@ -38,4 +43,14 @@ class PlayerSessionQueryImpl(private val presenceService: PlayerPresenceService) connectedAt = session.connectedAtMillis, ) } + + /** + * `servers` has one row per occupied backend server — a server nobody is on is absent, not a + * zero entry. + */ + internal fun toNetworkPlayerCounts(reply: CountPlayersByServerReply): NetworkPlayerCounts = + NetworkPlayerCounts( + byServer = reply.serversList.associate { it.serverName to it.players }, + total = reply.total, + ) } diff --git a/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt b/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt new file mode 100644 index 0000000..28d9e57 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/presence/PlayerSessionQueryImplTest.kt @@ -0,0 +1,43 @@ +package gg.grounds.presence + +import gg.grounds.grpc.player.CountPlayersByServerReply +import gg.grounds.grpc.player.ServerPlayerCount +import java.net.ServerSocket +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +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) + } + + @Test + fun countPlayersByServerReturnsNullWhenPresenceServiceIsUnavailable() { + val presenceService = PlayerPresenceService() + val unusedPort = ServerSocket(0).use { it.localPort } + presenceService.configure("localhost:$unusedPort") + + try { + assertNull(PlayerSessionQueryImpl(presenceService).countPlayersByServer()) + } finally { + presenceService.close() + } + } +}