diff --git a/README.md b/README.md index 13e06cf..64cbdb2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ ./gradlew build ``` +## Configuration + +The plugin requires the gRPC target to be provided via environment variable: + +```bash +export PLAYER_PRESENCE_GRPC_TARGET="dns:///service-player.api.svc.cluster.local:9000" +``` + +Messages are configured in `velocity/src/main/resources/messages.yml` (copied to the plugin data +directory on first run). + ## Development Run in dev mode with live reload using DevSpace in a Kubernetes cluster: diff --git a/build.gradle.kts b/build.gradle.kts index 18cca17..9ca9bb1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,25 +1 @@ -plugins { - base - java -} - -group = "gg.grounds" -version = "1.0.0-SNAPSHOT" - -allprojects { - repositories { - maven { - url = uri("https://repo.papermc.io/repository/maven-public/") - } - mavenCentral() - } -} - -subprojects { - apply(plugin = "java") - - java { - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 - } -} +plugins { id("gg.grounds.base-conventions") version "0.3.0" } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index e69de29..cce2133 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -0,0 +1,13 @@ +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() + } + } +} + +dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.1.0") } diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt new file mode 100644 index 0000000..5a34d1e --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -0,0 +1,82 @@ +package gg.grounds.player.presence + +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 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): PlayerLoginResult { + return try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .tryPlayerLogin( + PlayerLoginRequest.newBuilder().setPlayerId(playerId.toString()).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) + } + } + + fun logout(playerId: UUID): PlayerLogoutReply { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .playerLogout( + PlayerLogoutRequest.newBuilder().setPlayerId(playerId.toString()).build() + ) + } catch (e: StatusRuntimeException) { + errorLogoutReply(e.status.toString()) + } catch (e: RuntimeException) { + errorLogoutReply(e.message ?: e::class.java.name) + } + } + + 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() + } + } + + companion object { + fun create(target: String): GrpcPlayerPresenceClient { + val channelBuilder = ManagedChannelBuilder.forTarget(target) + channelBuilder.usePlaintext() + 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 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/PlayerLoginResult.kt b/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt new file mode 100644 index 0000000..e08adc3 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt @@ -0,0 +1,11 @@ +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/settings.gradle.kts b/settings.gradle.kts index e1fd2c3..7a9c346 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,16 @@ -rootProject.name = "grounds-plugin-player" +rootProject.name = "plugin-player" include("common", "velocity") + +pluginManagement { + repositories { + maven { + url = uri("https://maven.pkg.github.com/groundsgg/*") + credentials { + username = providers.gradleProperty("github.user").get() + password = providers.gradleProperty("github.token").get() + } + } + gradlePluginPortal() + } +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index f51b3c1..e835e17 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -1,23 +1,8 @@ -plugins { - id("com.gradleup.shadow") version "9.3.0" -} +plugins { id("gg.grounds.velocity-conventions") } dependencies { implementation(project(":common")) - compileOnly("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") - annotationProcessor("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") -} - -tasks.build { - dependsOn(tasks.shadowJar) -} - -tasks.jar { - enabled = false -} - -tasks.shadowJar { - archiveBaseName.set(rootProject.name) - archiveClassifier.set("") // Removes the 'all' classifier - archiveVersion.set("") // Removes the version from the jar name + implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.3") + implementation("tools.jackson.module:jackson-module-kotlin:3.0.3") + implementation("io.grpc:grpc-netty-shaded:1.78.0") } diff --git a/velocity/devspace.yaml b/velocity/devspace.yaml index 716f0fa..f4dd501 100644 --- a/velocity/devspace.yaml +++ b/velocity/devspace.yaml @@ -1,36 +1,36 @@ version: v2beta1 -name: velocity-player +name: plugin-player-velocity deployments: app: namespace: games helm: - releaseName: velocity-player + releaseName: plugin-player-velocity chart: - name: oci://ghcr.io/groundsgg/charts/grounds-service + name: oci://ghcr.io/groundsgg/charts/agones-fleet version: 0.1.0 values: - deployment: - image: - repository: velocity - containerPort: 25565 - service: - enabled: true - port: 25565 - targetPort: 25565 + image: + repository: ghcr.io/groundsgg/velocity + tag: 0.6.0 + serverType: velocity + command: ["/bin/sh", "-c", "sleep infinity"] dev: app: # Search for the container that runs this image - imageSelector: velocity - # Replace the container image with this image - devImage: ghcr.io/groundsgg/velocity:v0.2.0 + imageSelector: ghcr.io/groundsgg/velocity + # Synchronize the plugin jar file to the plugins directory sync: - path: ./build/libs/*.jar:/app/plugins/*.jar + initialSync: preferLocal file: true # Open a terminal and use the following command to start it terminal: + 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 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/java/gg/grounds/GroundsPluginPlayer.java b/velocity/src/main/java/gg/grounds/GroundsPluginPlayer.java deleted file mode 100644 index abc4e1e..0000000 --- a/velocity/src/main/java/gg/grounds/GroundsPluginPlayer.java +++ /dev/null @@ -1,32 +0,0 @@ -package gg.grounds; - -import com.google.inject.Inject; -import com.velocitypowered.api.event.Subscribe; -import com.velocitypowered.api.event.connection.DisconnectEvent; -import com.velocitypowered.api.event.connection.PostLoginEvent; -import com.velocitypowered.api.plugin.Plugin; -import org.slf4j.Logger; - -@Plugin(id = "grounds-plugin-player", name = "Grounds Player Plugin") -public final class GroundsPluginPlayer { - private final Logger logger; - - @Inject - public GroundsPluginPlayer(Logger logger) { - this.logger = logger; - - this.logger.info("VelocityPlayerPlugin initialized"); - } - - @Subscribe - public void onPostLogin(PostLoginEvent event) { - String name = event.getPlayer().getUsername(); - logger.info("hello {}", name); - } - - @Subscribe - public void onDisconnect(DisconnectEvent event) { - String name = event.getPlayer().getUsername(); - logger.info("bye {}", name); - } -} diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt new file mode 100644 index 0000000..e5f3bab --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -0,0 +1,81 @@ +package gg.grounds + +import com.google.inject.Inject +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent +import com.velocitypowered.api.plugin.Plugin +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.PlayerPresenceService +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 + +@Plugin( + id = "plugin-player", + name = "Grounds Player Plugin", + version = BuildInfo.VERSION, + description = "A plugin which manages player related actions and data transactions", + authors = ["Grounds Development Team and contributors"], + url = "https://github.com/groundsgg/plugin-player", +) +class GroundsPluginPlayer +@Inject +constructor( + private val proxy: ProxyServer, + private val logger: Logger, + @param:DataDirectory private val dataDirectory: Path, +) { + private val playerPresenceService = PlayerPresenceService() + + init { + logger.info("Initialized plugin (plugin=plugin-player, version={})", BuildInfo.VERSION) + } + + @Subscribe + fun onInitialize(event: ProxyInitializeEvent) { + registerProviders() + + val messages = MessagesConfigLoader(logger, dataDirectory).loadOrCreate() + val target = resolveTarget() + playerPresenceService.configure(target) + + proxy.eventManager.register( + this, + PlayerConnectionListener( + logger = logger, + playerPresenceService = playerPresenceService, + messages = messages, + ), + ) + + logger.info("Configured player presence gRPC client (target={})", target) + } + + @Subscribe + fun onShutdown(event: ProxyShutdownEvent) { + playerPresenceService.close() + } + + /** + * 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. + */ + 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") + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt new file mode 100644 index 0000000..946a6d0 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt @@ -0,0 +1,8 @@ +package gg.grounds.config + +data class MessagesConfig( + val serviceUnavailable: String = "Login service unavailable", + val alreadyOnline: String = "You are already online.", + val invalidRequest: String = "Invalid login request.", + val genericError: String = "Unable to create player session.", +) diff --git a/velocity/src/main/kotlin/gg/grounds/config/MessagesConfigLoader.kt b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfigLoader.kt new file mode 100644 index 0000000..881a8fb --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfigLoader.kt @@ -0,0 +1,68 @@ +package gg.grounds.config + +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import org.slf4j.Logger +import tools.jackson.databind.DeserializationFeature +import tools.jackson.dataformat.yaml.YAMLMapper +import tools.jackson.module.kotlin.KotlinModule +import tools.jackson.module.kotlin.readValue + +class MessagesConfigLoader(private val logger: Logger, private val dataDirectory: Path) { + private val mapper: YAMLMapper = + YAMLMapper.builder() + .addModule(KotlinModule.Builder().build()) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build() + + fun loadOrCreate(): MessagesConfig { + val yamlFile = dataDirectory.resolve("messages.yml") + + return try { + Files.createDirectories(dataDirectory) + + if (Files.notExists(yamlFile)) { + copyDefaultConfig(yamlFile) + } + + Files.newInputStream(yamlFile).use { input -> parseYaml(input) } + } catch (e: Exception) { + logger.warn("Failed to load messages config (path={})", yamlFile, e) + MessagesConfig() + } + } + + private fun parseYaml(input: InputStream): MessagesConfig { + return try { + mapper.readValue(input) + } catch (e: Exception) { + logger.warn("Failed to parse messages config; using defaults (path=messages.yml)", e) + MessagesConfig() + } + } + + private fun copyDefaultConfig(target: Path) { + val resourceName = "messages.yml" + val inputStream = javaClass.classLoader.getResourceAsStream(resourceName) + if (inputStream == null) { + logger.warn( + "Default messages resource missing; writing defaults (resource={}, path={})", + resourceName, + target, + ) + writeYaml(target, MessagesConfig()) + return + } + + inputStream.use { input -> + Files.newOutputStream(target).use { output -> input.copyTo(output) } + } + } + + private fun writeYaml(target: Path, config: MessagesConfig) { + Files.newOutputStream(target).use { output -> + output.writer(Charsets.UTF_8).use { writer -> mapper.writeValue(writer, config) } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt new file mode 100644 index 0000000..be56f94 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt @@ -0,0 +1,120 @@ +package gg.grounds.listener + +import com.velocitypowered.api.event.EventTask +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PreLoginEvent +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.presence.PlayerPresenceService +import java.util.UUID +import net.kyori.adventure.text.Component +import org.slf4j.Logger + +class PlayerConnectionListener( + private val logger: Logger, + private val playerPresenceService: PlayerPresenceService, + private val messages: MessagesConfig, +) { + @Subscribe + fun onPreLogin(event: PreLoginEvent): EventTask { + val name = event.username + val playerId = event.uniqueId ?: UuidUtils.generateOfflinePlayerUuid(name) + + return EventTask.async { + when (val result = playerPresenceService.tryLogin(playerId)) { + is PlayerLoginResult.Success -> { + if (handleSuccess(event, name, playerId, result.reply)) { + return@async + } + } + is PlayerLoginResult.Unavailable -> { + logger.warn( + "Player presence login unavailable (playerId={}, username={}, reason={})", + playerId, + name, + result.message, + ) + deny(event, messages.serviceUnavailable) + } + is PlayerLoginResult.Error -> { + logger.warn( + "Player presence login failed (playerId={}, username={}, reason={})", + playerId, + name, + result.message, + ) + deny(event, messages.genericError) + } + } + } + } + + @Subscribe + fun onDisconnect(event: DisconnectEvent): EventTask { + val playerId = event.player.uniqueId + val name = event.player.username + + return EventTask.async { + val result = playerPresenceService.logout(playerId) ?: 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 -> { + logger.info( + "Player session created (playerId={}, username={}, status={})", + playerId, + name, + reply.status, + ) + 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 + } + + 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) { + event.result = PreLoginEvent.PreLoginComponentResult.denied(Component.text(message)) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt new file mode 100644 index 0000000..ba6fa37 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -0,0 +1,37 @@ +package gg.grounds.presence + +import gg.grounds.grpc.player.PlayerLogoutReply +import gg.grounds.player.presence.GrpcPlayerPresenceClient +import gg.grounds.player.presence.PlayerLoginResult +import java.util.UUID + +class PlayerPresenceService : AutoCloseable { + private lateinit var client: GrpcPlayerPresenceClient + + fun configure(target: String) { + close() + client = GrpcPlayerPresenceClient.create(target) + } + + fun tryLogin(playerId: UUID): PlayerLoginResult { + return try { + client.tryLogin(playerId) + } catch (e: RuntimeException) { + PlayerLoginResult.Error(e.message ?: e::class.java.name) + } + } + + fun logout(playerId: UUID): PlayerLogoutReply? { + return try { + client.logout(playerId) + } catch (e: RuntimeException) { + null + } + } + + override fun close() { + if (this::client.isInitialized) { + client.close() + } + } +} diff --git a/velocity/src/main/resources/messages.yml b/velocity/src/main/resources/messages.yml new file mode 100644 index 0000000..ce7ef24 --- /dev/null +++ b/velocity/src/main/resources/messages.yml @@ -0,0 +1,4 @@ +serviceUnavailable: "Login service unavailable" +alreadyOnline: "You are already online." +invalidRequest: "Invalid login request." +genericError: "Unable to create player session."