diff --git a/common/build.gradle.kts b/common/build.gradle.kts index cce2133..1bc3fdd 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,4 +10,4 @@ repositories { } } -dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.1.0") } +dependencies { protobuf("gg.grounds:library-grpc-contracts-player:feat-player-heartbeat-SNAPSHOT") } 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 5a34d1e..680006f 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.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 @@ -49,6 +51,22 @@ private constructor( } } + 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) + } + } + override fun close() { channel.shutdown() try { @@ -74,6 +92,14 @@ private constructor( 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 diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index e5f3bab..c56e123 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -9,6 +9,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.config.MessagesConfigLoader import gg.grounds.listener.PlayerConnectionListener +import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry @@ -33,6 +34,8 @@ constructor( @param:DataDirectory private val dataDirectory: Path, ) { private val playerPresenceService = PlayerPresenceService() + private val heartbeatScheduler = + PlayerHeartbeatScheduler(this, proxy, logger, playerPresenceService) init { logger.info("Initialized plugin (plugin=plugin-player, version={})", BuildInfo.VERSION) @@ -55,11 +58,13 @@ constructor( ), ) + heartbeatScheduler.start() logger.info("Configured player presence gRPC client (target={})", target) } @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + heartbeatScheduler.stop() playerPresenceService.close() } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt new file mode 100644 index 0000000..8ae7dde --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt @@ -0,0 +1,61 @@ +package gg.grounds.presence + +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.scheduler.ScheduledTask +import java.util.concurrent.TimeUnit +import org.slf4j.Logger + +class PlayerHeartbeatScheduler( + private val plugin: Any, + private val proxy: ProxyServer, + private val logger: Logger, + private val presenceService: PlayerPresenceService, +) { + private var heartbeatTask: ScheduledTask? = null + + fun start() { + heartbeatTask?.cancel() + heartbeatTask = null + val heartbeatIntervalSeconds = resolveHeartbeatIntervalSeconds() + heartbeatTask = + proxy.scheduler + .buildTask(plugin, Runnable { sendHeartbeats() }) + .repeat(heartbeatIntervalSeconds, TimeUnit.SECONDS) + .schedule() + logger.info( + "Configured player presence heartbeat task (intervalSeconds={})", + heartbeatIntervalSeconds, + ) + } + + fun stop() { + heartbeatTask?.cancel() + heartbeatTask = null + } + + private fun sendHeartbeats() { + val playerIds = proxy.allPlayers.map { it.uniqueId } + if (playerIds.isEmpty()) { + return + } + + val result = presenceService.heartbeatBatch(playerIds) + if (!result.success) { + logger.error( + "Player session heartbeat batch failed (playerCount={}, error={})", + playerIds.size, + result.message, + ) + } else { + logger.debug( + "Player session heartbeat batch completed (playerCount={}, result=success)", + playerIds.size, + ) + } + } + + private fun resolveHeartbeatIntervalSeconds(): Long { + val raw = System.getenv("PLAYER_PRESENCE_HEARTBEAT_SECONDS") ?: return 30 + return raw.toLongOrNull()?.takeIf { it > 0 } ?: 30 + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index ba6fa37..a85de39 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -8,6 +8,8 @@ import java.util.UUID class PlayerPresenceService : AutoCloseable { private lateinit var client: GrpcPlayerPresenceClient + data class HeartbeatBatchResult(val success: Boolean, val message: String) + fun configure(target: String) { close() client = GrpcPlayerPresenceClient.create(target) @@ -29,6 +31,15 @@ class PlayerPresenceService : AutoCloseable { } } + fun heartbeatBatch(playerIds: Collection): HeartbeatBatchResult { + return try { + val reply = client.heartbeatBatch(playerIds) + HeartbeatBatchResult(reply.success, reply.message) + } catch (e: RuntimeException) { + HeartbeatBatchResult(false, e.message ?: e::class.java.name) + } + } + override fun close() { if (this::client.isInitialized) { client.close()