diff --git a/README.md b/README.md index 01099c81..f6c0d1da 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Docs: [https://surf-cloud-docs.netlify.app/](https://surf-cloud-docs.netlify.app ![logo](https://github.com/SLNE-Development/assets/blob/master/logos/surf-cloud/surf-cloud-logo-bg-removed.png?raw=true) +https://chatgpt.com/g/g-67f7a61dc2208191bea4663d0771d6dd-flyway-migration-generator # TODO ## IntelliJ Plugin diff --git a/cert_new/INSTRUCTIONS.md b/cert_new/INSTRUCTIONS.md new file mode 100644 index 00000000..deab122c --- /dev/null +++ b/cert_new/INSTRUCTIONS.md @@ -0,0 +1,174 @@ +# SSL Certificate Generation Guide for Netty Server and Clients + +This guide explains how to generate and manage SSL/TLS certificates for your Netty-based server and +clients using OpenSSL. Properly generated certificates ensure secure communication between your +server and clients. + +> **Note:** If you already have a server set up and only need to add a new client, you can skip directly +> to **[Step 4](#step-4-generate-client-certificates-repeatable-for-each-client)**. Ensure the following files are already present in your working directory: +> - `openssl.cnf` +> - `ca.key` +> - `ca.crt` + +--- + +## Prerequisites + +- Install [OpenSSL](https://slproweb.com/products/Win32OpenSSL.html) or use WSL/Git Bash. +- Ensure OpenSSL is available in your command prompt: + +```bash +openssl version +``` + +--- + +## Step-by-Step Guide + +### Step 1: Create an OpenSSL Configuration File (`openssl.cnf`) + +Create a file named `openssl.cnf` in your working directory: + +```ini +[ req ] +default_bits = 2048 +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[ req_distinguished_name ] +C = DE +ST = Remote +L = Internet +O = Surf Cloud +CN = YOUR_SERVER_IP_OR_DNS + +[ v3_req ] +subjectAltName = @alt_names +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth, clientAuth + +[ alt_names ] +IP.1 = YOUR_SERVER_IP +DNS.1 = YOUR_SERVER_DNS +``` + +Replace `YOUR_SERVER_IP` and `YOUR_SERVER_DNS` with your actual server IP address and DNS name. + +Example: + +```ini +CN = 192.168.1.23 + +[ alt_names ] +IP.1 = 192.168.1.23 +DNS.1 = myserver.local +``` + +--- + +### Step 2: Generate Your Own Certificate Authority (CA) + +Run the following commands once to create your CA: + +```bash +openssl genrsa -out ca.key 2048 + +openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/C=DE/ST=Remote/L=Internet/O=Surf Cloud/CN=SurfCloud CA" +``` + +**Important:** + +- `ca.key`: Private key of your CA (keep this secret and safe). +- `ca.crt`: Public certificate of your CA (clients and server use this to establish trust). + +--- + +### Step 3: Generate Server Certificate + +```bash +# Generate private key +openssl genrsa -out server.key 2048 + +# Generate CSR (Certificate Signing Request) +openssl req -new -key server.key -out server.csr -config openssl.cnf + +# Sign CSR with your CA +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -extfile openssl.cnf -extensions v3_req +``` + +**Files produced:** + +- `server.key`: Server's private key (use on your Netty server). +- `server.crt`: Server's signed certificate (distribute to clients). + +--- + +### Step 4: Generate Client Certificates (repeatable for each client) + +Modify `openssl.cnf` by changing the `CN` to your client name (`client01`, `client02`, etc.): + +```ini +CN = test-server01 +``` + +Then run: + +```bash +openssl genrsa -out client.key 2048 + +openssl req -new -key client.key -out client.csr -config openssl.cnf + +openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365 -extfile openssl.cnf -extensions v3_req +``` + +**Files produced:** + +- `client.key`: Client's private key (keep secret and only on the client). +- `client.crt`: Client's signed certificate (put this on your server to authenticate the + client). + +--- + +## File Management + +### Files to Keep and Their Usage + +| File | Purpose | Store on | +|--------------|----------------------------------------------|--------------------------------------| +| `ca.key` | CA private key (needed for signing) | Secure offline storage (never share) | +| `ca.crt` | CA certificate (trusted by all participants) | Server and Clients | +| `server.key` | Server private key | Server (secure) | +| `server.crt` | Server certificate (signed by CA) | Server (send to clients) | +| `client.key` | Client private key | Client (secure) | +| `client.crt` | Client certificate (signed by CA) | Server (trust this certificate) | + +### Directory Structure + +**On Server:** + +``` +certificates/ +├── server.key +├── server.crt +└── ca.crt +``` + +**On Client:** + +``` +certificates/ +├── client.key +├── client.crt +└── ca.crt +``` + +--- + +## Common Errors and Fixes + +- **Hostname/IP undefined:** Ensure your server certificate includes the correct IP/DNS in SAN. +- **certificate_unknown:** Ensure the client certificate is correctly placed in + `certificates/clients/` and signed by the CA. + +--- \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/event/player/connection/CloudPlayerDisconnectFromNetworkEvent.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/event/player/connection/CloudPlayerDisconnectFromNetworkEvent.kt new file mode 100644 index 00000000..4f48e07c --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/event/player/connection/CloudPlayerDisconnectFromNetworkEvent.kt @@ -0,0 +1,13 @@ +package dev.slne.surf.cloud.api.common.event.player.connection + +import dev.slne.surf.cloud.api.common.event.player.CloudPlayerEvent +import dev.slne.surf.cloud.api.common.player.CloudPlayer +import java.io.Serial + +class CloudPlayerDisconnectFromNetworkEvent(source: Any, player: CloudPlayer) : + CloudPlayerEvent(source, player) { + companion object { + @Serial + private const val serialVersionUID: Long = 1730514074081406974L + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/CloudBufSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/CloudBufSerializer.kt new file mode 100644 index 00000000..5d3cea80 --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/CloudBufSerializer.kt @@ -0,0 +1,10 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx + +import dev.slne.surf.bytebufserializer.KBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import dev.slne.surf.cloud.api.common.util.annotation.InternalApi + +@InternalApi +abstract class CloudBufSerializer : KBufSerializer { + override val bufClass = SurfByteBuf::class +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/SurfCloudBufSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/SurfCloudBufSerializer.kt new file mode 100644 index 00000000..504fff2c --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/SurfCloudBufSerializer.kt @@ -0,0 +1,43 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx + +import dev.slne.surf.bytebufserializer.Buf +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.adventure.AdventureComponentSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.adventure.AdventureKeySerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.adventure.AdventureSoundSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.BitSetSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.Inet4AddressSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.InetSocketAddressSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.URISerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.UUIDSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.UtfStringSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java.ZonedDateTimeSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.kotlin.DurationSerializer +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.nbt.CompoundTagSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual + +object SurfCloudBufSerializer { + val serializerModule = SerializersModule { + // Adventure + contextual(AdventureKeySerializer) + contextual(AdventureSoundSerializer) + contextual(AdventureComponentSerializer) + + // Java + contextual(UUIDSerializer) + contextual(BitSetSerializer) + contextual(UtfStringSerializer) + contextual(URISerializer) + contextual(InetSocketAddressSerializer) + contextual(ZonedDateTimeSerializer) + contextual(Inet4AddressSerializer) + + // Kotlin + contextual(DurationSerializer) + + // NBT + contextual(CompoundTagSerializer) + } + + val serializer = Buf(serializerModule) +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureComponentSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureComponentSerializer.kt new file mode 100644 index 00000000..39d1f6be --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureComponentSerializer.kt @@ -0,0 +1,25 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.adventure + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import net.kyori.adventure.text.Component + +typealias SerializableComponent = @Serializable(with = AdventureComponentSerializer::class) Component + +object AdventureComponentSerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("Component", PrimitiveKind.STRING) + + override fun serialize0( + buf: SurfByteBuf, + value: Component + ) { + buf.writeComponent(value) + } + + override fun deserialize0(buf: SurfByteBuf): Component { + return buf.readComponent() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureKeySerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureKeySerializer.kt new file mode 100644 index 00000000..c936ca79 --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureKeySerializer.kt @@ -0,0 +1,25 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.adventure + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import net.kyori.adventure.key.Key + +typealias SerializableKey = @Serializable(with = AdventureKeySerializer::class) Key + +object AdventureKeySerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("Key", PrimitiveKind.STRING) + + override fun serialize0( + buf: SurfByteBuf, + value: Key + ) { + buf.writeKey(value) + } + + override fun deserialize0(buf: SurfByteBuf): Key { + return buf.readKey() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureSoundSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureSoundSerializer.kt new file mode 100644 index 00000000..f637dcc5 --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/adventure/AdventureSoundSerializer.kt @@ -0,0 +1,32 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.adventure + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import net.kyori.adventure.sound.Sound + +typealias SerializableSound = @Serializable(with = AdventureSoundSerializer::class) Sound + +object AdventureSoundSerializer : CloudBufSerializer() { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Sound") { + element("source") + element("volume") + element("pitch") + element("seed") + element("name") + } + + override fun serialize0( + buf: SurfByteBuf, + value: Sound + ) { + buf.writeSound(value) + } + + override fun deserialize0(buf: SurfByteBuf): Sound { + return buf.readSound() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/BitSetSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/BitSetSerializer.kt new file mode 100644 index 00000000..8c9da8d7 --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/BitSetSerializer.kt @@ -0,0 +1,27 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import java.util.* + +typealias SerializableBitSet = @Serializable(with = BitSetSerializer::class) BitSet + +object BitSetSerializer : CloudBufSerializer() { + override val descriptor = buildClassSerialDescriptor("BitSet") { + element("longArray") + } + + override fun serialize0( + buf: SurfByteBuf, + value: BitSet + ) { + buf.writeBitSet(value) + } + + override fun deserialize0(buf: SurfByteBuf): BitSet { + return buf.readBitSet() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/Inet4AddressSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/Inet4AddressSerializer.kt new file mode 100644 index 00000000..66addd4e --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/Inet4AddressSerializer.kt @@ -0,0 +1,25 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import java.net.Inet4Address + +typealias SerializableInet4Address = @Serializable(with = Inet4AddressSerializer::class) Inet4Address + +object Inet4AddressSerializer: CloudBufSerializer() { + override val descriptor = SerialDescriptor("Inet4Address", ByteArraySerializer().descriptor) + + override fun serialize0( + buf: SurfByteBuf, + value: Inet4Address + ) { + buf.writeInet4Address(value) + } + + override fun deserialize0(buf: SurfByteBuf): Inet4Address { + return buf.readInet4Address() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/InetSocketAddressSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/InetSocketAddressSerializer.kt new file mode 100644 index 00000000..6d9ad8bc --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/InetSocketAddressSerializer.kt @@ -0,0 +1,28 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import java.net.InetSocketAddress + +typealias SerializableInetSocketAddress = @Serializable(with = InetSocketAddressSerializer::class) InetSocketAddress + +object InetSocketAddressSerializer : CloudBufSerializer() { + override val descriptor = buildClassSerialDescriptor("InetSocketAddress") { + element("host") + element("port") + } + + override fun serialize0( + buf: SurfByteBuf, + value: InetSocketAddress + ) { + buf.writeInetSocketAddress(value) + } + + override fun deserialize0(buf: SurfByteBuf): InetSocketAddress { + return buf.readInetSocketAddress() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/URISerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/URISerializer.kt new file mode 100644 index 00000000..2d1e6a9c --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/URISerializer.kt @@ -0,0 +1,25 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import java.net.URI + +typealias SerializableURI = @Serializable(with = URISerializer::class) URI + +object URISerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("URI", PrimitiveKind.STRING) + + override fun serialize0( + buf: SurfByteBuf, + value: URI + ) { + buf.writeURI(value) + } + + override fun deserialize0(buf: SurfByteBuf): URI { + return buf.readURI() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/UUIDSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/UUIDSerializer.kt new file mode 100644 index 00000000..67317d2b --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/UUIDSerializer.kt @@ -0,0 +1,28 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import java.util.UUID + +typealias SerializableUUID = @Serializable(with = UUIDSerializer::class) UUID + +object UUIDSerializer: CloudBufSerializer() { + override val descriptor = buildClassSerialDescriptor("UUID") { + element("mostSignificantBits") + element("leastSignificantBits") + } + + override fun serialize0( + buf: SurfByteBuf, + value: UUID + ) { + buf.writeUuid(value) + } + + override fun deserialize0(buf: SurfByteBuf): UUID { + return buf.readUuid() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/UtfStringSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/UtfStringSerializer.kt new file mode 100644 index 00000000..b24ab99d --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/UtfStringSerializer.kt @@ -0,0 +1,21 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor + +typealias SerializableUtfString = @Serializable(with = UtfStringSerializer::class) String + +object UtfStringSerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("Utf8String", PrimitiveKind.STRING) + + override fun serialize0(buf: SurfByteBuf, value: String) { + buf.writeUtf(value) + } + + override fun deserialize0(buf: SurfByteBuf): String { + return buf.readUtf() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/VarIntSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/VarIntSerializer.kt new file mode 100644 index 00000000..c7deb99a --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/VarIntSerializer.kt @@ -0,0 +1,21 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor + +typealias IntAsVarInt = @Serializable(with = VarIntSerializer::class) Int + +object VarIntSerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("VarInt", PrimitiveKind.INT) + + override fun serialize0(buf: SurfByteBuf, value: Int) { + buf.writeVarInt(value) + } + + override fun deserialize0(buf: SurfByteBuf): Int { + return buf.readVarInt() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/VarLongSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/VarLongSerializer.kt new file mode 100644 index 00000000..96fe6240 --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/VarLongSerializer.kt @@ -0,0 +1,21 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor + +typealias LongAsVarLong = @Serializable(with = VarLongSerializer::class) Long + +object VarLongSerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("VarLong", PrimitiveKind.LONG) + + override fun serialize0(buf: SurfByteBuf, value: Long) { + buf.writeVarLong(value) + } + + override fun deserialize0(buf: SurfByteBuf): Long { + return buf.readVarLong() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/ZonedDateTimeSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/ZonedDateTimeSerializer.kt new file mode 100644 index 00000000..44b6cf2b --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/java/ZonedDateTimeSerializer.kt @@ -0,0 +1,28 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.java + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import java.time.ZonedDateTime + +typealias SerializableZonedDateTime = @Serializable(with = ZonedDateTimeSerializer::class) ZonedDateTime + +object ZonedDateTimeSerializer : CloudBufSerializer() { + override val descriptor = buildClassSerialDescriptor("ZonedDateTime") { + element("epochMillis") + element("zoneId") + } + + override fun deserialize0(buf: SurfByteBuf): ZonedDateTime { + return buf.readZonedDateTime() + } + + override fun serialize0( + buf: SurfByteBuf, + value: ZonedDateTime + ) { + buf.writeZonedDateTime(value) + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/kotlin/DurationSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/kotlin/DurationSerializer.kt new file mode 100644 index 00000000..b6c23df3 --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/kotlin/DurationSerializer.kt @@ -0,0 +1,25 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.kotlin + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlin.time.Duration + +typealias SerializableDuration = @Serializable(with = DurationSerializer::class) Duration + +object DurationSerializer : CloudBufSerializer() { + override val descriptor = PrimitiveSerialDescriptor("Duration", PrimitiveKind.LONG) + + override fun serialize0( + buf: SurfByteBuf, + value: Duration + ) { + buf.writeDuration(value) + } + + override fun deserialize0(buf: SurfByteBuf): Duration { + return buf.readDuration() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/nbt/CompoundTagSerializer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/nbt/CompoundTagSerializer.kt new file mode 100644 index 00000000..23c9f93c --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/network/codec/kotlinx/nbt/CompoundTagSerializer.kt @@ -0,0 +1,25 @@ +package dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.nbt + +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.CloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import net.querz.nbt.tag.CompoundTag + +typealias SerializableCompoundTag = @Serializable(with = CompoundTagSerializer::class) CompoundTag + +object CompoundTagSerializer : CloudBufSerializer() { + override val descriptor = SerialDescriptor("CompoundTag", ByteArraySerializer().descriptor) + + override fun serialize0( + buf: SurfByteBuf, + value: CompoundTag + ) { + buf.writeCompoundTag(value) + } + + override fun deserialize0(buf: SurfByteBuf): CompoundTag { + return buf.readCompoundTag() + } +} \ No newline at end of file diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/packet/packet-extension.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/packet/packet-extension.kt index 2528084d..7cf1a6f2 100644 --- a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/packet/packet-extension.kt +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/packet/packet-extension.kt @@ -1,15 +1,17 @@ package dev.slne.surf.cloud.api.common.netty.packet -import dev.slne.surf.bytebufserializer.Buf import dev.slne.surf.cloud.api.common.meta.PacketCodec import dev.slne.surf.cloud.api.common.meta.SurfNettyPacket import dev.slne.surf.cloud.api.common.netty.network.codec.StreamCodec import dev.slne.surf.cloud.api.common.netty.network.codec.StreamDecoder import dev.slne.surf.cloud.api.common.netty.network.codec.StreamMemberEncoder +import dev.slne.surf.cloud.api.common.netty.network.codec.kotlinx.SurfCloudBufSerializer +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf import dev.slne.surf.cloud.api.common.util.mutableObject2ObjectMapOf import io.netty.buffer.ByteBuf import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer import kotlinx.serialization.serializerOrNull import kotlin.reflect.KClass import kotlin.reflect.full.companionObject @@ -65,6 +67,20 @@ private const val DEFAULT_STREAM_CODEC_NAME = "STREAM_CODEC" private val codecCache = mutableObject2ObjectMapOf, StreamCodec<*, *>>() +@OptIn(InternalSerializationApi::class) +fun

KClass.createCodec(): StreamCodec { + val serializer = serializer() + return object : StreamCodec { + override fun decode(buf: SurfByteBuf): P { + return SurfCloudBufSerializer.serializer.decodeFromBuf(buf, serializer) + } + + override fun encode(buf: SurfByteBuf, value: P) { + SurfCloudBufSerializer.serializer.encodeToBuf(buf, serializer as KSerializer

, value) + } + } +} + /** * Finds a [StreamCodec] for the specified packet type if available. * @@ -82,11 +98,11 @@ fun KClass.findPacketCodec(): StreamCodec< if (serializer != null) { return object : StreamCodec { override fun decode(buf: B): V { - return Buf.decodeFromBuf(buf, serializer) + return SurfCloudBufSerializer.serializer.decodeFromBuf(buf, serializer) } override fun encode(buf: B, value: V) { - Buf.encodeToBuf(buf, serializer as KSerializer, value) + SurfCloudBufSerializer.serializer.encodeToBuf(buf, serializer as KSerializer, value) } } } diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/protocol/buffer/SurfByteBuf.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/protocol/buffer/SurfByteBuf.kt index 5785b81f..52ccbecc 100644 --- a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/protocol/buffer/SurfByteBuf.kt +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/netty/protocol/buffer/SurfByteBuf.kt @@ -17,7 +17,6 @@ import dev.slne.surf.cloud.api.common.netty.protocol.buffer.types.VarLong import dev.slne.surf.cloud.api.common.util.codec.ExtraCodecs import dev.slne.surf.cloud.api.common.util.createUnresolvedInetSocketAddress import dev.slne.surf.cloud.api.common.util.fromJson -import dev.slne.surf.surfapi.core.api.util.getCallerClass import io.netty.buffer.ByteBuf import io.netty.handler.codec.DecoderException import io.netty.handler.codec.EncoderException @@ -39,6 +38,8 @@ import java.util.function.Consumer import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract import kotlin.reflect.KClass +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds private const val NUMBER_BYTE: Byte = 0 private const val NUMBER_SHORT: Byte = 1 @@ -661,6 +662,14 @@ open class SurfByteBuf(source: ByteBuf) : WrappedByteBuf(source) { return Class.forName(className, true, classLoader).kotlin.objectInstance ?: throw DecoderException("Failed to read singleton: $className") } + + fun writeDuration(buf: B, duration: Duration) { + buf.writeLong(duration.inWholeMilliseconds) + } + + fun readDuration(buf: B): Duration { + return buf.readLong().milliseconds + } } @@ -836,6 +845,8 @@ open class SurfByteBuf(source: ByteBuf) : WrappedByteBuf(source) { fun readInet4Address() = readInet4Address(this) fun writeSingleton(singleton: Any) = writeSingleton(this, singleton) fun readSingleton(classLoader: ClassLoader) = readSingleton(this, classLoader) + fun writeDuration(duration: Duration) = writeDuration(this, duration) + fun readDuration() = readDuration(this) // @formatter:on // endregion @@ -1068,6 +1079,9 @@ fun B.writeInet4Address(address: Inet4Address) = SurfByteBuf.write fun B.readSingleton(classLoader: ClassLoader) = SurfByteBuf.readSingleton(this, classLoader) fun B.writeSingleton(singleton: Any) = SurfByteBuf.writeSingleton(this, singleton) +fun B.writeDuration(duration: Duration) = SurfByteBuf.writeDuration(this, duration) +fun B.readDuration() = SurfByteBuf.readDuration(this) + fun ByteBuf.wrap() = SurfByteBuf(this) // endregion diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/CloudPlayer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/CloudPlayer.kt index ff974e7c..703a8327 100644 --- a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/CloudPlayer.kt +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/CloudPlayer.kt @@ -9,6 +9,7 @@ import net.kyori.adventure.audience.Audience import net.kyori.adventure.text.Component import java.net.Inet4Address import java.util.* +import kotlin.time.Duration /** * Represents a player connected to the cloud infrastructure. @@ -35,6 +36,9 @@ interface CloudPlayer : Audience, OfflineCloudPlayer { // TODO: conversation but */ val connected get() = connectedToProxy || connectedToServer + suspend fun isAfk(): Boolean + suspend fun currentSessionDuration(): Duration + /** * Performs modifications on the player's persistent data container. * diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/OfflineCloudPlayer.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/OfflineCloudPlayer.kt index 431211ce..611e1c7d 100644 --- a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/OfflineCloudPlayer.kt +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/OfflineCloudPlayer.kt @@ -1,6 +1,7 @@ package dev.slne.surf.cloud.api.common.player import dev.slne.surf.cloud.api.common.player.name.NameHistory +import dev.slne.surf.cloud.api.common.player.playtime.Playtime import dev.slne.surf.cloud.api.common.server.CloudServer import net.kyori.adventure.text.Component import java.net.Inet4Address @@ -17,9 +18,11 @@ interface OfflineCloudPlayer { suspend fun lastServerRaw(): String? suspend fun lastServer(): CloudServer? suspend fun lastSeen(): ZonedDateTime? + suspend fun firstSeen(): ZonedDateTime? suspend fun latestIpAddress(): Inet4Address? suspend fun playedBefore(): Boolean + suspend fun playtime(): Playtime /** * Returns the online player instance if the player is currently connected. diff --git a/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/playtime/Playtime.kt b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/playtime/Playtime.kt new file mode 100644 index 00000000..c0596c0c --- /dev/null +++ b/surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/playtime/Playtime.kt @@ -0,0 +1,182 @@ +package dev.slne.surf.cloud.api.common.player.playtime + +import dev.slne.surf.cloud.api.common.server.CloudServer +import io.netty.buffer.ByteBuf +import it.unimi.dsi.fastutil.objects.Object2ObjectMap +import it.unimi.dsi.fastutil.objects.ObjectList +import it.unimi.dsi.fastutil.objects.ObjectSet +import java.time.ZonedDateTime +import kotlin.time.Duration + +/** + * Provides a comprehensive analytical view of playtime data, allowing various queries and analyses + * based on servers, categories, and timeframes. + * + * This interface is immutable and provides methods to perform detailed aggregations and analytics + * without altering the underlying data. + */ +interface Playtime { + + /** + * Returns the total playtime across all servers and categories. + * + * @param since Optional start time. If provided, only playtime after this timestamp is considered. + * @return The summed total playtime as a [Duration]. + */ + fun sumPlaytimes(since: ZonedDateTime? = null): Duration + + /** + * Returns the total playtime for a specific category. + * + * @param category The category to filter by. + * @param since Optional start time to filter playtime. + * @return The summed total playtime for the category as a [Duration]. + */ + fun sumByCategory(category: String, since: ZonedDateTime? = null): Duration + + /** + * Returns the total playtime on a specific server identified by its name. + * + * @param server The server name to filter by. + * @param since Optional start time to filter playtime. + * @return The summed total playtime for the specified server as a [Duration]. + */ + fun sumByServer(server: String, since: ZonedDateTime? = null): Duration + + /** + * Returns the total playtime on a specific [CloudServer]. + * + * @param server The [CloudServer] to filter by. + * @param since Optional start time to filter playtime. + * @return The summed total playtime for the specified server as a [Duration]. + */ + fun sumByServer(server: CloudServer, since: ZonedDateTime? = null): Duration { + return sumByServer(server.name, since) + } + + /** + * Returns a set of all distinct categories present in the playtime data. + * + * @return An [ObjectSet] of unique category names. + */ + fun getCategories(): ObjectSet + + /** + * Returns a set of all distinct server names present in the playtime data. + * + * @return An [ObjectSet] of unique server names. + */ + fun getServers(): ObjectSet + + /** + * Returns the total playtime for a specific server and optionally a specific category. + * + * @param server The server name. + * @param category Optional category to further filter results. + * @param since Optional start time to filter playtime. + * @return The summed total playtime matching the specified filters as a [Duration]. + */ + fun playtimeFor( + server: String, + category: String? = null, + since: ZonedDateTime? = null + ): Duration + + /** + * Returns a mapping of servers to their respective total playtime durations. + * + * @param since Optional start time to filter playtime. + * @return An [Object2ObjectMap] where keys are server names and values are durations. + */ + fun playtimesPerServer(since: ZonedDateTime? = null): Object2ObjectMap + + /** + * Returns a mapping of categories to their respective total playtime durations. + * + * @param since Optional start time to filter playtime. + * @return An [Object2ObjectMap] where keys are category names and values are durations. + */ + fun playtimesPerCategory(since: ZonedDateTime? = null): Object2ObjectMap + + fun playtimePerCategoryPerServer(since: ZonedDateTime? = null): Object2ObjectMap> + + /** + * Returns the average playtime per server, optionally filtered by category and start time. + * + * @param category Optional category to filter by. + * @param since Optional start time to filter playtime. + * @return The average playtime across servers as a [Duration]. + */ + fun averagePlaytimePerServer(category: String? = null, since: ZonedDateTime? = null): Duration + + /** + * Generates a timeline mapping timestamps to accumulated playtime durations, grouped by specified intervals. + * Each interval represents a bucket of time starting at the beginning of the interval and includes the total + * playtime that occurred within that interval. This is particularly useful for analyzing player activity trends, + * identifying peak playing times, or generating visualizations such as heatmaps and activity charts. + * + * For example, if you choose an hourly interval, each timestamp in the resulting map will correspond + * precisely to the start of that hour, with its associated duration representing the sum of all playtime + * recorded between that hour and the start of the next hour. + * + * ### Example use case: + * + * Suppose you want to analyze player activity throughout the last day to determine peak gaming hours on + * the server named "PvP-Arena" within the "competitive" category. You could use: + * + * ```kotlin + * val hourlyTimeline = playtime.timeline( + * interval = 1.hours, + * category = "competitive", + * server = "PvP-Arena" + * ) + * + * hourlyTimeline.forEach { (hour, duration) -> + * println("Playtime from $hour to ${hour.plusHours(1)}: $duration") + * } + * ``` + * + * The resulting output might look like: + * + * ``` + * Playtime from 2025-04-08T14:00Z to 2025-04-08T15:00Z: 30m + * Playtime from 2025-04-08T15:00Z to 2025-04-08T16:00Z: 45m + * Playtime from 2025-04-08T16:00Z to 2025-04-08T17:00Z: 1h + * ... + * ``` + * + * This clearly illustrates player activity peaks, enabling targeted actions such as scheduling server events, + * balancing loads, or informing community engagement strategies. + * + * @param interval The duration of each interval (e.g., hourly, daily, weekly). + * @param category Optional category filter. If specified, only playtime matching this category is considered. + * @param server Optional server filter. If specified, only playtime on this particular server is included. + * @return An [Object2ObjectMap] mapping interval-start timestamps to the total accumulated playtime durations + * within each interval. + */ + fun timeline( + interval: Duration, + category: String? = null, + server: String? = null + ): Object2ObjectMap + + /** + * Retrieves a ranked list of servers sorted by total playtime in descending order. + * + * @param limit Maximum number of results to return. + * @param since Optional start time to filter playtime. + * @return An [ObjectList] of pairs, each containing a server name and its corresponding playtime duration. + */ + fun topServers(limit: Int = 5, since: ZonedDateTime? = null): ObjectList> + + /** + * Retrieves a ranked list of categories sorted by total playtime in descending order. + * + * @param limit Maximum number of results to return. + * @param since Optional start time to filter playtime. + * @return An [ObjectList] of pairs, each containing a category name and its corresponding playtime duration. + */ + fun topCategories(limit: Int = 5, since: ZonedDateTime? = null): ObjectList> + + fun writeToByteBuf(buf: ByteBuf) +} \ No newline at end of file diff --git a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/BukkitMain.kt b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/BukkitMain.kt index 91020169..011d00ea 100644 --- a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/BukkitMain.kt +++ b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/BukkitMain.kt @@ -16,6 +16,7 @@ import dev.slne.surf.cloud.api.common.server.CloudServerManager import dev.slne.surf.cloud.bukkit.player.BukkitClientCloudPlayerImpl import dev.slne.surf.cloud.core.common.handleEventuallyFatalError import dev.slne.surf.surfapi.bukkit.api.event.listen +import dev.slne.surf.surfapi.core.api.messages.Colors import dev.slne.surf.surfapi.core.api.messages.CommonComponents import dev.slne.surf.surfapi.core.api.messages.adventure.buildText import dev.slne.surf.surfapi.core.api.messages.adventure.sendText @@ -279,6 +280,89 @@ class BukkitMain : SuspendingJavaPlugin() { sender.sendPlainMessage("Test packet sent") } } + + commandAPICommand("playtime") { + offlinePlayerArgument("player") + anyExecutor { sender, args -> + val player: OfflinePlayer by args + val cloudPlayer = player.toCloudOfflinePlayer() + launch { + val playtime = cloudPlayer.playtime() + val complete = playtime.sumPlaytimes() + val playtimeMap = playtime.playtimePerCategoryPerServer() + + sender.sendText { + appendPrefix() + info("Playtime for player ${player.name} (${player.uniqueId})") + appendNewPrefixedLine() + appendNewPrefixedLine { + variableKey("Total") + spacer(": ") + variableValue(complete.toString()) + } + appendNewPrefixedLine() + for ((group, groupServer) in playtimeMap) { + appendNewPrefixedLine { + spacer("- ") + variableKey(group) + spacer(": ") + variableValue(playtime.sumByCategory(group).toString()) + + for ((serverName, playtime) in groupServer) { + appendNewPrefixedLine { + text(" ") + variableKey(serverName) + spacer(": ") + variableValue(playtime.toString()) + } + } + appendNewPrefixedLine() + } + } + } + } + } + } + + commandAPICommand("lastSeen") { + offlinePlayerArgument("player") + anyExecutor { sender, args -> + val player: OfflinePlayer by args + val cloudPlayer = player.toCloudOfflinePlayer() + launch { + val lastSeen = cloudPlayer.lastSeen() + sender.sendText { + appendPrefix() + info("Last seen for player ${player.name} (${player.uniqueId})") + appendNewPrefixedLine { + variableKey("Last Seen") + spacer(": ") + variableValue(lastSeen?.toString() ?: "#Unknown") + } + } + } + } + } + + commandAPICommand("currentSessionDuration") { + entitySelectorArgumentOnePlayer("player") + anyExecutor { sender, args -> + val player: Player by args + val cloudPlayer = player.toCloudPlayer() + launch { + val currentSessionDuration = cloudPlayer?.currentSessionDuration() + sender.sendText { + appendPrefix() + info("Current session duration for player ${player.name} (${player.uniqueId})") + appendNewPrefixedLine { + variableKey("Current Session Duration") + spacer(": ") + variableValue(currentSessionDuration?.toString() ?: "#Unknown") + } + } + } + } + } } @OptIn(ExperimentalContracts::class) diff --git a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/CloudBukkitInstance.kt b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/CloudBukkitInstance.kt index 1716cded..e48965b2 100644 --- a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/CloudBukkitInstance.kt +++ b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/CloudBukkitInstance.kt @@ -4,8 +4,10 @@ import com.google.auto.service.AutoService import dev.slne.surf.cloud.api.common.CloudInstance import dev.slne.surf.cloud.bukkit.listener.ListenerManager import dev.slne.surf.cloud.bukkit.netty.BukkitNettyManager +import dev.slne.surf.cloud.bukkit.processor.BukkitListenerProcessor import dev.slne.surf.cloud.core.client.ClientCommonCloudInstance import dev.slne.surf.cloud.core.common.coreCloudInstance +import dev.slne.surf.cloud.core.common.util.bean import dev.slne.surf.cloud.core.common.util.checkInstantiationByServiceLoader @AutoService(CloudInstance::class) @@ -17,6 +19,7 @@ class CloudBukkitInstance : ClientCommonCloudInstance(BukkitNettyManager) { override suspend fun onEnable() { super.onEnable() + bean().registerListeners() ListenerManager.registerListeners() } diff --git a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/listener/player/PlayerAfkListener.kt b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/listener/player/PlayerAfkListener.kt new file mode 100644 index 00000000..34b6795e --- /dev/null +++ b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/listener/player/PlayerAfkListener.kt @@ -0,0 +1,62 @@ +package dev.slne.surf.cloud.bukkit.listener.player + +import dev.slne.surf.cloud.api.client.netty.packet.fireAndForget +import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundUpdateAFKState +import dev.slne.surf.surfapi.core.api.util.mutableObject2BooleanMapOf +import dev.slne.surf.surfapi.core.api.util.mutableObject2LongMapOf +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.event.player.PlayerMoveEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +@Component +class PlayerAfkListener : Listener { + private val afkTime = 10.seconds.inWholeMilliseconds + private val lastMovedTime = mutableObject2LongMapOf() + private val currentSentState = mutableObject2BooleanMapOf() + + @EventHandler + fun onPlayerMove(event: PlayerMoveEvent) { + if (!event.hasChangedOrientation()) return + if (!event.hasChangedPosition()) return + lastMovedTime[event.player.uniqueId] = System.currentTimeMillis() + } + + @EventHandler + fun onPlayerJoin(event: PlayerJoinEvent) { + lastMovedTime[event.player.uniqueId] = System.currentTimeMillis() + } + + @EventHandler + fun onPlayerQuit(event: PlayerQuitEvent) { + val uuid = event.player.uniqueId + lastMovedTime.removeLong(uuid) + currentSentState.removeBoolean(uuid) + } + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) + fun afkCheckTask() { + val currentTime = System.currentTimeMillis() + + lastMovedTime.object2LongEntrySet().fastForEach { entry -> + val uuid = entry.key + val lastMoved = entry.longValue + val timeSinceLastMove = currentTime - lastMoved + val isAfk = timeSinceLastMove >= afkTime + val previousState = currentSentState.put(uuid, isAfk) + if (previousState != isAfk) { + broadcastChange(uuid, isAfk) + } + } + } + + private fun broadcastChange(uuid: UUID, isAfk: Boolean) { + ServerboundUpdateAFKState(uuid, isAfk).fireAndForget() + } +} \ No newline at end of file diff --git a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/processor/BukkitListenerProcessor.kt b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/processor/BukkitListenerProcessor.kt index 5080ec78..3ab5fd85 100644 --- a/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/processor/BukkitListenerProcessor.kt +++ b/surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/processor/BukkitListenerProcessor.kt @@ -2,6 +2,7 @@ package dev.slne.surf.cloud.bukkit.processor import dev.slne.surf.cloud.api.common.util.isAnnotated import dev.slne.surf.cloud.api.common.util.isCandidateFor +import dev.slne.surf.cloud.api.common.util.mutableObjectListOf import dev.slne.surf.cloud.api.common.util.selectFunctions import dev.slne.surf.cloud.api.common.util.ultimateTargetClass import org.bukkit.Bukkit @@ -21,6 +22,8 @@ import java.lang.reflect.Method @Component class BukkitListenerProcessor : BeanPostProcessor { + private val listeners = mutableObjectListOf() + override fun postProcessAfterInitialization(bean: Any, beanName: String): Any { if (bean is AopInfrastructureBean) return bean @@ -52,7 +55,7 @@ class BukkitListenerProcessor : BeanPostProcessor { } val eventParam = params[0] - if (!eventParam.isAssignableFrom(Event::class.java)) { + if (!Event::class.java.isAssignableFrom(eventParam)) { throw BeanCreationException( beanName, "Event handler method parameter must be a subclass of Event" @@ -77,7 +80,26 @@ class BukkitListenerProcessor : BeanPostProcessor { throw EventException(e, "Error invoking event handler") } } - registerEventHandler(bean, eventClass, eventHandler, eventExecutor) + + listeners.add( + ListenerMetaData( + bean, + eventClass, + eventHandler, + eventExecutor + ) + ) + } + } + + fun registerListeners() { + for (listener in listeners) { + registerEventHandler( + listener.bean, + listener.event, + listener.eventHandler, + listener.eventExecutor + ) } } @@ -104,4 +126,11 @@ class BukkitListenerProcessor : BeanPostProcessor { private fun getPluginFromBean(bean: Any): JavaPlugin { return JavaPlugin.getProvidingPlugin(bean.javaClass) } + + data class ListenerMetaData( + val bean: Any, + val event: Class, + val eventHandler: EventHandler, + val eventExecutor: EventExecutor + ) } diff --git a/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/netty/network/ClientEncryptionManager.kt b/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/netty/network/ClientEncryptionManager.kt index 541a939d..09f08ce9 100644 --- a/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/netty/network/ClientEncryptionManager.kt +++ b/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/netty/network/ClientEncryptionManager.kt @@ -1,32 +1,32 @@ package dev.slne.surf.cloud.core.client.netty.network -import dev.slne.surf.cloud.api.common.config.properties.CloudProperties +import dev.slne.surf.cloud.core.common.config.cloudConfig import dev.slne.surf.cloud.core.common.netty.network.EncryptionManager import dev.slne.surf.cloud.core.common.netty.network.HandlerNames import dev.slne.surf.surfapi.core.api.util.logger import io.netty.channel.Channel import io.netty.handler.ssl.SslContext import io.netty.handler.ssl.SslContextBuilder +import io.netty.handler.ssl.SslProvider +import kotlin.io.path.div object ClientEncryptionManager : EncryptionManager() { - private val log = logger() private lateinit var sslContext: SslContext - private val clientCertificateFile = - certificatesFolder.resolve("${CloudProperties.SERVER_NAME}.crt").toFile() - private val clientKeyFile = - certificatesFolder.resolve("${CloudProperties.SERVER_NAME}.key").toFile() - private val serverCertificate = certificatesFolder.resolve("server.crt").toFile() + private val clientCertificateFile = (certificatesFolder / "client.crt").toFile() + private val clientKeyFile = (certificatesFolder / "client.key").toFile() + private val trustManagerFile = (certificatesFolder / "ca.crt").toFile() override fun setupEncryption(ch: Channel) { + val config = cloudConfig.connectionConfig.nettyConfig ch.pipeline().addFirst( HandlerNames.SSL_HANDLER, - sslContext.newHandler(ch.alloc()) + sslContext.newHandler(ch.alloc(), config.host, config.port) ) } override suspend fun init() { - waitForFiles(clientCertificateFile, clientKeyFile, serverCertificate) + waitForFiles(clientCertificateFile, clientKeyFile, trustManagerFile) sslContext = buildSslContext() } @@ -34,7 +34,8 @@ object ClientEncryptionManager : EncryptionManager() { return SslContextBuilder .forClient() .keyManager(clientCertificateFile, clientKeyFile) - .trustManager(serverCertificate) + .trustManager(trustManagerFile) + .sslProvider(SslProvider.JDK) .build() } } \ No newline at end of file diff --git a/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/ClientCloudPlayerImpl.kt b/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/ClientCloudPlayerImpl.kt index 50495d2a..990cffb8 100644 --- a/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/ClientCloudPlayerImpl.kt +++ b/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/ClientCloudPlayerImpl.kt @@ -6,6 +6,7 @@ import dev.slne.surf.cloud.api.client.netty.packet.fireAndForget import dev.slne.surf.cloud.api.common.netty.packet.DEFAULT_URGENT_TIMEOUT import dev.slne.surf.cloud.api.common.player.ConnectionResult import dev.slne.surf.cloud.api.common.player.name.NameHistory +import dev.slne.surf.cloud.api.common.player.playtime.Playtime import dev.slne.surf.cloud.api.common.player.ppdc.PersistentPlayerDataContainer import dev.slne.surf.cloud.api.common.player.teleport.TeleportCause import dev.slne.surf.cloud.api.common.player.teleport.TeleportFlag @@ -14,10 +15,7 @@ import dev.slne.surf.cloud.api.common.server.CloudServer import dev.slne.surf.cloud.core.client.util.luckperms import dev.slne.surf.cloud.core.common.netty.network.protocol.running.* import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket.DataRequestType -import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.IpAddress -import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.LastServer -import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.Name -import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.NameHistory as NameHistoryResponse +import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.* import dev.slne.surf.cloud.core.common.player.CommonCloudPlayerImpl import dev.slne.surf.cloud.core.common.player.ppdc.PersistentPlayerDataContainerImpl import dev.slne.surf.surfapi.core.api.messages.adventure.getPointer @@ -36,8 +34,10 @@ import net.kyori.adventure.title.TitlePart import net.luckperms.api.model.user.User import net.luckperms.api.platform.PlayerAdapter import java.net.Inet4Address +import java.time.ZonedDateTime import java.util.* import kotlin.time.Duration +import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.NameHistory as NameHistoryResponse abstract class ClientCloudPlayerImpl(uuid: UUID) : CommonCloudPlayerImpl(uuid) { @@ -49,7 +49,6 @@ abstract class ClientCloudPlayerImpl(uuid: UUID) : override val connectedToProxy get() = proxyServerUid != null override val connectedToServer get() = serverUid != null - /** * The audience for this player. If the player is on this server, this will point to * the bukkit / velocity player. Otherwise packets will be sent to the player via the network. @@ -70,6 +69,18 @@ abstract class ClientCloudPlayerImpl(uuid: UUID) : return request(DataRequestType.NAME_HISTORY).history } + override suspend fun firstSeen(): ZonedDateTime? { + return request(DataRequestType.FIRST_SEEN).firstSeen + } + + override suspend fun isAfk(): Boolean { + return request(DataRequestType.IS_AFK).isAfk + } + + override suspend fun currentSessionDuration(): Duration { + return request(DataRequestType.PLAYTIME_SESSION).playtime + } + override suspend fun withPersistentData(block: PersistentPlayerDataContainer.() -> R): R { val response = ServerboundRequestPlayerPersistentDataContainer(uuid).fireAndAwaitOrThrow() @@ -96,6 +107,10 @@ abstract class ClientCloudPlayerImpl(uuid: UUID) : ?: error("Failed to get display name (probably timed out)") } + override suspend fun playtime(): Playtime { + return request(DataRequestType.PLAYTIME).playtime + } + override suspend fun name(): String { val localName = audience?.getPointer(Identity.NAME) if (localName != null) { @@ -318,7 +333,7 @@ abstract class ClientCloudPlayerImpl(uuid: UUID) : return block(luckperms.getPlayerAdapter(platformClass)) } - private suspend inline fun request( + private suspend inline fun request( type: DataRequestType ): T { val response = ServerboundRequestPlayerDataPacket(uuid, type).fireAndAwaitOrThrow().data diff --git a/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/OfflineCloudPlayerImpl.kt b/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/OfflineCloudPlayerImpl.kt index 3c15c4e2..c762ad06 100644 --- a/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/OfflineCloudPlayerImpl.kt +++ b/surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/OfflineCloudPlayerImpl.kt @@ -2,6 +2,7 @@ package dev.slne.surf.cloud.core.client.player import dev.slne.surf.cloud.api.client.netty.packet.fireAndAwaitOrThrow import dev.slne.surf.cloud.api.common.player.name.NameHistory +import dev.slne.surf.cloud.api.common.player.playtime.Playtime import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket.DataRequestType import dev.slne.surf.cloud.core.common.netty.network.protocol.running.getGenericValue @@ -24,6 +25,10 @@ class OfflineCloudPlayerImpl(uuid: UUID) : CommonOfflineCloudPlayerImpl(uuid) { return request(DataRequestType.LAST_SEEN) } + override suspend fun firstSeen(): ZonedDateTime? { + return request(DataRequestType.FIRST_SEEN) + } + override suspend fun latestIpAddress(): Inet4Address? { return request(DataRequestType.LATEST_IP_ADDRESS) } @@ -36,6 +41,10 @@ class OfflineCloudPlayerImpl(uuid: UUID) : CommonOfflineCloudPlayerImpl(uuid) { return request(DataRequestType.NAME) } + override suspend fun playtime(): Playtime { + return request(DataRequestType.PLAYTIME) + } + override suspend fun getLuckpermsMetaData( key: String, transformer: (String) -> R diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/coroutines/scopes.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/coroutines/scopes.kt index 6adbff31..47a86904 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/coroutines/scopes.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/coroutines/scopes.kt @@ -120,4 +120,9 @@ object NameHistoryScope : BaseScope( object PlayerDatabaseScope : BaseScope( dispatcher = newSingleThreadContext("player-database-thread"), name = "player-database" +) + +object PlayerPlaytimeScope : BaseScope( + dispatcher = Dispatchers.IO, + name = "player-playtime" ) \ No newline at end of file diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/ConnectionImpl.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/ConnectionImpl.kt index 51c7b065..2cbb8152 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/ConnectionImpl.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/ConnectionImpl.kt @@ -25,16 +25,15 @@ import dev.slne.surf.surfapi.core.api.util.logger import io.netty.bootstrap.Bootstrap import io.netty.channel.* import io.netty.channel.epoll.Epoll -import io.netty.channel.epoll.EpollEventLoopGroup +import io.netty.channel.epoll.EpollIoHandler import io.netty.channel.epoll.EpollSocketChannel -import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.nio.NioIoHandler import io.netty.channel.socket.SocketChannel import io.netty.channel.socket.nio.NioSocketChannel import io.netty.handler.codec.EncoderException import io.netty.handler.codec.compression.ZstdDecoder import io.netty.handler.codec.compression.ZstdEncoder import io.netty.handler.flow.FlowControlHandler -import io.netty.handler.logging.LogLevel import io.netty.handler.logging.LoggingHandler import io.netty.handler.timeout.ReadTimeoutHandler import io.netty.handler.timeout.TimeoutException @@ -344,6 +343,7 @@ class ConnectionImpl( is TeleportPlayerPacket -> listener.handleTeleportPlayer(msg) is ServerboundShutdownServerPacket -> listener.handleShutdownServer(msg) is ServerboundRequestPlayerDataPacket -> listener.handleRequestPlayerData(msg) + is ServerboundUpdateAFKState -> listener.handleUpdateAFKState(msg) else -> listener.handlePacket(msg) // handle other packets } @@ -981,24 +981,22 @@ class ConnectionImpl( companion object { private val log = logger() - val NETWORK_WORKER_GROUP: NioEventLoopGroup by lazy { - NioEventLoopGroup( + val NETWORK_WORKER_GROUP: MultiThreadIoEventLoopGroup by lazy { + MultiThreadIoEventLoopGroup( threadFactory { nameFormat("Netty Client IO #%d") daemon(true) uncaughtExceptionHandler(DefaultUncaughtExceptionHandlerWithName(log)) - } + }, NioIoHandler.newFactory() ) } - val NETWORK_EPOLL_WORKER_GROUP: EpollEventLoopGroup by lazy { - EpollEventLoopGroup( - threadFactory { - nameFormat("Netty Epoll Client IO #%d") - daemon(true) - uncaughtExceptionHandler(DefaultUncaughtExceptionHandlerWithName(log)) - } - ) + val NETWORK_EPOLL_WORKER_GROUP: MultiThreadIoEventLoopGroup by lazy { + MultiThreadIoEventLoopGroup(threadFactory { + nameFormat("Netty Epoll Client IO #%d") + daemon(true) + uncaughtExceptionHandler(DefaultUncaughtExceptionHandlerWithName(log)) + }, EpollIoHandler.newFactory()) } private val INITIAL_PROTOCOL = HandshakeProtocols.SERVERBOUND diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/EncryptionManager.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/EncryptionManager.kt index d299c93b..906984c3 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/EncryptionManager.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/EncryptionManager.kt @@ -1,18 +1,18 @@ package dev.slne.surf.cloud.core.common.netty.network import dev.slne.surf.cloud.core.common.coreCloudInstance -import dev.slne.surf.surfapi.core.api.util.logger import io.netty.channel.Channel import kotlinx.coroutines.delay import java.io.File import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.div import kotlin.time.Duration.Companion.seconds abstract class EncryptionManager { - private val log = logger() protected val certificatesFolder: Path by lazy { - coreCloudInstance.dataFolder.resolve("certificates").also { it.toFile().mkdirs() } + (coreCloudInstance.dataFolder / "certificates").createDirectories() } abstract fun setupEncryption(ch: Channel) @@ -24,8 +24,10 @@ abstract class EncryptionManager { val missingFiles = files.filter { !it.exists() }.toMutableList() while (missingFiles.isNotEmpty()) { - log.atInfo() - .log("Waiting for missing files: ${missingFiles.joinToString { it.path }}") +// log.atInfo() +// .log("Waiting for missing files: ${missingFiles.joinToString { it.path }}") + + println("[INFO] ${this::class.simpleName}: Waiting for missing files: ${missingFiles.joinToString { it.path }}") delay(5.seconds) missingFiles.removeIf { it.exists() } diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningProtocols.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningProtocols.kt index 18d3c9d5..19fc2cad 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningProtocols.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningProtocols.kt @@ -1,6 +1,8 @@ package dev.slne.surf.cloud.core.common.netty.network.protocol.running import dev.slne.surf.cloud.api.common.netty.network.ConnectionProtocol +import dev.slne.surf.cloud.api.common.netty.packet.createCodec +import dev.slne.surf.cloud.api.common.netty.packet.findPacketCodec import dev.slne.surf.cloud.api.common.netty.protocol.buffer.SurfByteBuf import dev.slne.surf.cloud.core.common.netty.network.protocol.ProtocolInfoBuilder import dev.slne.surf.cloud.core.common.netty.network.protocol.common.* @@ -95,6 +97,7 @@ object RunningProtocols { .addPacket(ServerboundShutdownServerPacket.STREAM_CODEC) .addPacket(RequestOfflineDisplayNamePacket.STREAM_CODEC) .addPacket(ServerboundRequestPlayerDataPacket.STREAM_CODEC) + .addPacket(ServerboundUpdateAFKState::class.createCodec()) } val SERVERBOUND by lazy { SERVERBOUND_TEMPLATE.freeze().bind(::SurfByteBuf) } diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningServerPacketListener.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningServerPacketListener.kt index e191d15b..77e30f68 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningServerPacketListener.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/RunningServerPacketListener.kt @@ -63,6 +63,8 @@ interface RunningServerPacketListener : ServerCommonPacketListener, TickablePack suspend fun handleShutdownServer(packet: ServerboundShutdownServerPacket) suspend fun handleRequestPlayerData(packet: ServerboundRequestPlayerDataPacket) + + fun handleUpdateAFKState(packet: ServerboundUpdateAFKState) fun handlePacket(packet: NettyPacket) } \ No newline at end of file diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundRequestPlayerDataPacket.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundRequestPlayerDataPacket.kt index 0f4f15b3..19cafe9e 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundRequestPlayerDataPacket.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundRequestPlayerDataPacket.kt @@ -11,11 +11,14 @@ import dev.slne.surf.cloud.api.common.netty.protocol.buffer.readEnum import dev.slne.surf.cloud.api.common.player.OfflineCloudPlayer import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket.DataRequestType import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.* +import dev.slne.surf.cloud.core.common.player.playtime.PlaytimeImpl import net.kyori.adventure.text.Component import java.net.Inet4Address import java.time.ZonedDateTime import java.util.* +import kotlin.time.Duration import dev.slne.surf.cloud.api.common.player.name.NameHistory as ApiNameHistory +import dev.slne.surf.cloud.api.common.player.playtime.Playtime as ApiPlaytime @SurfNettyPacket("cloud:request:player_data", PacketFlow.SERVERBOUND) class ServerboundRequestPlayerDataPacket(val uuid: UUID, val type: DataRequestType) : @@ -55,6 +58,11 @@ class ServerboundRequestPlayerDataPacket(val uuid: UUID, val type: DataRequestTy return LastSeen(player.lastSeen()) } }, + FIRST_SEEN(::FirstSeen) { + override suspend fun readData(player: OfflineCloudPlayer): DataResponse { + return FirstSeen(player.firstSeen()) + } + }, DISPLAY_NAME(::DisplayName) { override suspend fun readData(player: OfflineCloudPlayer): DataResponse { return DisplayName(player.displayName()) @@ -69,6 +77,23 @@ class ServerboundRequestPlayerDataPacket(val uuid: UUID, val type: DataRequestTy override suspend fun readData(player: OfflineCloudPlayer): DataResponse { return NameHistory(player.nameHistory()) } + }, + PLAYTIME(::Playtime) { + override suspend fun readData(player: OfflineCloudPlayer): DataResponse { + return Playtime(player.playtime()) + } + }, + IS_AFK(::IsAFK) { + override suspend fun readData(player: OfflineCloudPlayer): DataResponse { + val player= player.player ?: error("Player is not online") + return IsAFK(player.isAfk()) + } + }, + PLAYTIME_SESSION(::PlaytimeSession) { + override suspend fun readData(player: OfflineCloudPlayer): DataResponse { + val player= player.player ?: error("Player is not online") + return PlaytimeSession(player.currentSessionDuration()) + } }; abstract suspend fun readData(player: OfflineCloudPlayer): DataResponse @@ -123,6 +148,14 @@ class ServerboundRequestPlayerDataResponse(val data: DataResponse) : ResponseNet } } + class FirstSeen(val firstSeen: ZonedDateTime?) : DataResponse(DataRequestType.FIRST_SEEN) { + constructor(buf: SurfByteBuf) : this(buf.readNullable { it.readZonedDateTime() }) + + override fun write(buf: SurfByteBuf) { + buf.writeNullable(firstSeen) { buf, dateTime -> buf.writeZonedDateTime(dateTime) } + } + } + class DisplayName(val displayName: Component?) : DataResponse(DataRequestType.DISPLAY_NAME) { constructor(buf: SurfByteBuf) : this(buf.readNullable { it.readComponent() }) @@ -146,14 +179,42 @@ class ServerboundRequestPlayerDataResponse(val data: DataResponse) : ResponseNet history.writeToByteBuf(buf) } } + + class Playtime(val playtime: ApiPlaytime) : DataResponse(DataRequestType.PLAYTIME) { + constructor(buf: SurfByteBuf) : this(PlaytimeImpl.readFromByteBuf(buf)) + + override fun write(buf: SurfByteBuf) { + playtime.writeToByteBuf(buf) + } + } + + class IsAFK(val isAfk: Boolean) : DataResponse(DataRequestType.IS_AFK) { + constructor(buf: SurfByteBuf) : this(buf.readBoolean()) + + override fun write(buf: SurfByteBuf) { + buf.writeBoolean(isAfk) + } + } + + class PlaytimeSession(val playtime: Duration) : DataResponse(DataRequestType.PLAYTIME_SESSION) { + constructor(buf: SurfByteBuf) : this(buf.readDuration()) + + override fun write(buf: SurfByteBuf) { + buf.writeDuration(playtime) + } + } } inline fun DataResponse.getGenericValue(): T = when (this) { is IpAddress -> check(T::class == Inet4Address::class) { "Expected Inet4Address" }.let { ip as T } is LastServer -> check(T::class == String::class) { "Expected String" }.let { server as T } is LastSeen -> check(T::class == ZonedDateTime::class) { "Expected ZonedDateTime" }.let { lastSeen as T } + is FirstSeen -> check(T::class == ZonedDateTime::class) { "Expected ZonedDateTime" }.let { firstSeen as T } is DisplayName -> check(T::class == Component::class) { "Expected Component" }.let { displayName as T } is Name -> check(T::class == String::class) { "Expected String" }.let { name as T } is NameHistory -> check(T::class == ApiNameHistory::class) { "Expected ApiNameHistory" }.let { history as T } + is Playtime -> check(T::class == ApiPlaytime::class) { "Expected ApiPlaytime" }.let { playtime as T } + is IsAFK -> check(T::class == Boolean::class) { "Expected Boolean" }.let { isAfk as T } + is PlaytimeSession -> check(T::class == Duration::class) { "Expected Duration" }.let { playtime as T } else -> error("Unknown DataResponse type: ${this::class.simpleName}") } \ No newline at end of file diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundUpdateAFKState.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundUpdateAFKState.kt new file mode 100644 index 00000000..abe4ec07 --- /dev/null +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundUpdateAFKState.kt @@ -0,0 +1,12 @@ +package dev.slne.surf.cloud.core.common.netty.network.protocol.running + +import dev.slne.surf.cloud.api.common.meta.SurfNettyPacket +import dev.slne.surf.cloud.api.common.netty.network.protocol.PacketFlow +import dev.slne.surf.cloud.api.common.netty.packet.NettyPacket +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.util.* + +@SurfNettyPacket("cloud:serverbound:update_afk_state", PacketFlow.SERVERBOUND) +@Serializable +class ServerboundUpdateAFKState(val uuid: @Contextual UUID, val isAfk: Boolean) : NettyPacket() \ No newline at end of file diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/CloudPlayerManagerImpl.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/CloudPlayerManagerImpl.kt index e3c07018..01cf98e3 100644 --- a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/CloudPlayerManagerImpl.kt +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/CloudPlayerManagerImpl.kt @@ -1,18 +1,21 @@ package dev.slne.surf.cloud.core.common.player import dev.slne.surf.cloud.api.common.event.player.connection.CloudPlayerConnectToNetworkEvent +import dev.slne.surf.cloud.api.common.event.player.connection.CloudPlayerDisconnectFromNetworkEvent import dev.slne.surf.cloud.api.common.player.CloudPlayerManager import dev.slne.surf.cloud.api.common.server.UserList import dev.slne.surf.cloud.api.common.server.UserListImpl import dev.slne.surf.cloud.api.common.util.mutableObject2ObjectMapOf import dev.slne.surf.cloud.api.common.util.synchronize import dev.slne.surf.cloud.core.common.util.publish +import dev.slne.surf.surfapi.core.api.util.logger import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap import org.jetbrains.annotations.MustBeInvokedByOverriders import java.net.Inet4Address import java.util.* abstract class CloudPlayerManagerImpl

: CloudPlayerManager { + private val log = logger() protected val players = mutableObject2ObjectMapOf().synchronize() override fun getPlayer(uuid: UUID?): P? { @@ -118,11 +121,24 @@ abstract class CloudPlayerManagerImpl

: CloudPlayerMa @MustBeInvokedByOverriders open suspend fun onNetworkDisconnect(uuid: UUID, player: P, oldProxy: Long?, oldServer: Long?) { + try { + CloudPlayerDisconnectFromNetworkEvent(this, player).publish() + } catch (e: Throwable) { + log.atWarning() + .withCause(e) + .log("Failed to publish CloudPlayerDisconnectFromNetworkEvent") + } } @MustBeInvokedByOverriders open suspend fun onNetworkConnect(uuid: UUID, player: P) { - CloudPlayerConnectToNetworkEvent(this, player).publish() + try { + CloudPlayerConnectToNetworkEvent(this, player).publish() + } catch (e: Throwable) { + log.atWarning() + .withCause(e) + .log("Failed to publish CloudPlayerConnectToNetworkEvent") + } } open fun terminate() {} diff --git a/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/playtime/PlaytimeImpl.kt b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/playtime/PlaytimeImpl.kt new file mode 100644 index 00000000..f8e4ae2a --- /dev/null +++ b/surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/playtime/PlaytimeImpl.kt @@ -0,0 +1,213 @@ +package dev.slne.surf.cloud.core.common.player.playtime + +import dev.slne.surf.cloud.api.common.netty.protocol.buffer.* +import dev.slne.surf.cloud.api.common.player.playtime.Playtime +import dev.slne.surf.cloud.api.common.util.* +import io.netty.buffer.ByteBuf +import it.unimi.dsi.fastutil.objects.Object2ObjectMap +import it.unimi.dsi.fastutil.objects.ObjectList +import it.unimi.dsi.fastutil.objects.ObjectSet +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.time.Instant +import java.time.ZonedDateTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class PlaytimeImpl(private val entries: ObjectList) : Playtime { + override fun sumPlaytimes(since: ZonedDateTime?): Duration = entries + .filter { since == null || it.createdAt.isAfter(since) } + .sumOf { it.durationSeconds } + .seconds + + override fun sumByCategory( + category: String, + since: ZonedDateTime? + ): Duration = entries + .filter { + it.category.equals(category, ignoreCase = true) + && (since == null || it.createdAt.isAfter(since)) + } + .sumOf { it.durationSeconds } + .seconds + + override fun sumByServer( + server: String, + since: ZonedDateTime? + ): Duration = entries + .filter { + it.server.equals(server, ignoreCase = true) + && (since == null || it.createdAt.isAfter(since)) + } + .sumOf { it.durationSeconds } + .seconds + + override fun getCategories(): ObjectSet = + entries.mapTo(mutableObjectSetOf()) { it.category } + + override fun getServers(): ObjectSet = + entries.mapTo(mutableObjectSetOf()) { it.server } + + override fun playtimeFor( + server: String, + category: String?, + since: ZonedDateTime? + ): Duration = entries + .filter { + it.server.equals(server, ignoreCase = true) + && (category == null || it.category.equals(category, ignoreCase = true)) + && (since == null || it.createdAt.isAfter(since)) + } + .sumOf { it.durationSeconds } + .seconds + + override fun playtimesPerServer(since: ZonedDateTime?): Object2ObjectMap = + entries.filter { since == null || it.createdAt.isAfter(since) } + .groupBy { it.server } + .mapValuesTo(mutableObject2ObjectMapOf()) { (_, group) -> + group.sumOf { it.durationSeconds }.seconds + } + + override fun playtimesPerCategory(since: ZonedDateTime?): Object2ObjectMap = + entries.filter { since == null || it.createdAt.isAfter(since) } + .groupBy { it.category } + .mapValuesTo(mutableObject2ObjectMapOf()) { (_, group) -> + group.sumOf { it.durationSeconds }.seconds + } + + override fun playtimePerCategoryPerServer(since: ZonedDateTime?): Object2ObjectMap> = + entries.filter { since == null || it.createdAt.isAfter(since) } + .groupBy { it.category } + .mapValuesTo(mutableObject2ObjectMapOf()) { (_, groupEntries) -> + groupEntries.groupBy { it.server } + .mapValuesTo(mutableObject2ObjectMapOf()) { (_, serverEntries) -> + serverEntries.sumOf { it.durationSeconds }.seconds + } + } + + + override fun averagePlaytimePerServer( + category: String?, + since: ZonedDateTime? + ): Duration { + val sumsPerServer = entries + .filter { + (category == null || it.category.equals(category, ignoreCase = true)) && + (since == null || it.createdAt.isAfter(since)) + } + .groupBy { it.server } + .mapValues { (_, group) -> group.sumOf { it.durationSeconds } } + .values + + return if (sumsPerServer.isEmpty()) Duration.ZERO + else sumsPerServer.average().seconds + } + + override fun timeline( + interval: Duration, + category: String?, + server: String? + ): Object2ObjectMap { + val map = mutableObject2ObjectMapOf() + + for (entry in entries) { + if (category != null && !entry.category.equals(category, ignoreCase = true)) continue + if (server != null && !entry.server.equals(server, ignoreCase = true)) continue + + val bucket = floorToInterval(entry.createdAt, interval) + map.merge(bucket, entry.durationSeconds.seconds) { a, b -> a + b } + } + + return map + } + + override fun topServers( + limit: Int, + since: ZonedDateTime? + ): ObjectList> = entries + .filter { since == null || it.createdAt.isAfter(since) } + .groupBy { it.server } + .mapValues { (_, group) -> group.sumOf { it.durationSeconds }.seconds } + .toList() + .sortedByDescending { it.second } + .take(limit) + .toObjectList() + + override fun topCategories( + limit: Int, + since: ZonedDateTime? + ): ObjectList> = entries + .filter { since == null || it.createdAt.isAfter(since) } + .groupBy { it.category } + .mapValues { (_, group) -> group.sumOf { it.durationSeconds }.seconds } + .toList() + .sortedByDescending { it.second } + .take(limit) + .toObjectList() + + override fun writeToByteBuf(buf: ByteBuf) { + buf.writeCollection(entries) { buf, entry -> entry.writeToByteBuf(buf) } + } + + companion object { + val EMPTY = PlaytimeImpl(objectListOf()) + + fun readFromByteBuf(buf: ByteBuf): PlaytimeImpl { + val entries = buf.readCollection( + { mutableObjectListOf(it) }, + { PlaytimeEntry.readFromByteBuf(it) } + ) + return PlaytimeImpl(entries) + } + } +} + +/** + * Floors the given [ZonedDateTime] to the nearest interval defined by the [Duration]. + * + * @param time The [ZonedDateTime] to be floored. + * @param interval The [Duration] representing the interval to floor to. + * @return A new [ZonedDateTime] floored to the nearest interval. + */ +private fun floorToInterval(time: ZonedDateTime, interval: Duration): ZonedDateTime { + val seconds = interval.inWholeSeconds + val epochSeconds = time.toEpochSecond() + val floored = (epochSeconds / seconds) * seconds + return ZonedDateTime.ofInstant(Instant.ofEpochSecond(floored), time.zone) +} + +/** + * Represents a single entry in the playtime data. + * + * @property category The category of the playtime entry. + * @property server The server associated with the playtime entry. + * @property durationSeconds The duration of playtime in seconds. + * @property createdAt The timestamp when the playtime entry was created. + */ +@Serializable +data class PlaytimeEntry( + val id: Long?, + val category: String, + val server: String, + val durationSeconds: Long, + val createdAt: @Contextual ZonedDateTime, +) { + fun writeToByteBuf(buf: ByteBuf) { + buf.writeNullableLong(id) + buf.writeUtf(category) + buf.writeUtf(server) + buf.writeLong(durationSeconds) + buf.writeZonedDateTime(createdAt) + } + + companion object { + fun readFromByteBuf(buf: ByteBuf): PlaytimeEntry { + val id = buf.readNullableLong() + val category = buf.readUtf() + val server = buf.readUtf() + val durationSeconds = buf.readLong() + val createdAt = buf.readZonedDateTime() + return PlaytimeEntry(id, category, server, durationSeconds, createdAt) + } + } +} diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/connection/ServerConnectionListener.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/connection/ServerConnectionListener.kt index e91a6cc4..55af2505 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/connection/ServerConnectionListener.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/connection/ServerConnectionListener.kt @@ -2,8 +2,11 @@ package dev.slne.surf.cloud.standalone.netty.server.connection import dev.slne.surf.cloud.api.common.netty.network.protocol.PacketFlow import dev.slne.surf.cloud.api.common.netty.packet.NettyPacket -import dev.slne.surf.cloud.api.common.util.* +import dev.slne.surf.cloud.api.common.util.DefaultUncaughtExceptionHandlerWithName +import dev.slne.surf.cloud.api.common.util.mutableObjectListOf import dev.slne.surf.cloud.api.common.util.netty.suspend +import dev.slne.surf.cloud.api.common.util.synchronize +import dev.slne.surf.cloud.api.common.util.threadFactory import dev.slne.surf.cloud.core.common.config.cloudConfig import dev.slne.surf.cloud.core.common.coroutines.ConnectionManagementScope import dev.slne.surf.cloud.core.common.netty.network.ConnectionImpl @@ -18,10 +21,10 @@ import dev.slne.surf.surfapi.core.api.util.logger import io.netty.bootstrap.ServerBootstrap import io.netty.channel.* import io.netty.channel.epoll.Epoll -import io.netty.channel.epoll.EpollEventLoopGroup +import io.netty.channel.epoll.EpollIoHandler import io.netty.channel.epoll.EpollServerDomainSocketChannel import io.netty.channel.epoll.EpollServerSocketChannel -import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.nio.NioIoHandler import io.netty.channel.socket.nio.NioServerSocketChannel import io.netty.channel.unix.DomainSocketAddress import io.netty.handler.flush.FlushConsolidationHandler @@ -215,22 +218,20 @@ class ServerConnectionListener(val server: NettyServerImpl) { private val log = logger() val SERVER_EVENT_GROUP by lazy { - NioEventLoopGroup( - threadFactory { - nameFormat("Netty Server IO #%d") - daemon(true) - uncaughtExceptionHandler(DefaultUncaughtExceptionHandlerWithName(log)) - } - ) + MultiThreadIoEventLoopGroup(threadFactory { + nameFormat("Netty Server IO #%d") + daemon(true) + uncaughtExceptionHandler(DefaultUncaughtExceptionHandlerWithName(log)) + }, NioIoHandler.newFactory()) } val SERVER_EPOLL_EVENT_GROUP by lazy { - EpollEventLoopGroup( + MultiThreadIoEventLoopGroup( threadFactory { nameFormat("Netty Epoll Server IO #%d") daemon(true) uncaughtExceptionHandler(DefaultUncaughtExceptionHandlerWithName(log)) - } + }, EpollIoHandler.newFactory() ) } } diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerEncryptionManager.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerEncryptionManager.kt index 32d6219b..39d07e1e 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerEncryptionManager.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerEncryptionManager.kt @@ -6,15 +6,12 @@ import io.netty.channel.Channel import io.netty.handler.ssl.ClientAuth import io.netty.handler.ssl.SslContext import io.netty.handler.ssl.SslContextBuilder -import java.security.KeyStore -import java.security.cert.CertificateFactory -import javax.net.ssl.TrustManagerFactory +import kotlin.io.path.div object ServerEncryptionManager : EncryptionManager() { - private val serverCertificateFile = certificatesFolder.resolve("server.crt").toFile() - private val serverKeyFile = certificatesFolder.resolve("server.key").toFile() - private val clientCertificatesFolder = - certificatesFolder.resolve("clients").also { it.toFile().mkdirs() } + private val serverCertificateFile = (certificatesFolder / "server.crt").toFile() + private val serverKeyFile = (certificatesFolder / "server.key").toFile() + private val trustManagerFile = (certificatesFolder / "ca.crt").toFile() override fun setupEncryption(ch: Channel) { ch.pipeline().addFirst( @@ -25,32 +22,32 @@ object ServerEncryptionManager : EncryptionManager() { } override suspend fun init() { - waitForFiles(serverCertificateFile, serverKeyFile) + waitForFiles(serverCertificateFile, serverKeyFile, trustManagerFile) } private fun buildSslContext(): SslContext { return SslContextBuilder .forServer(serverCertificateFile, serverKeyFile) - .trustManager(buildTrustManager()) + .trustManager(trustManagerFile) .clientAuth(ClientAuth.REQUIRE) .build() } - private fun buildTrustManager(): TrustManagerFactory { - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } - - clientCertificatesFolder.toFile().listFiles { file -> file.extension == "crt" } - ?.forEachIndexed { index, certFile -> - certFile.inputStream().use { inputStream -> - val certificate = - CertificateFactory.getInstance("X.509").generateCertificate(inputStream) - keyStore.setCertificateEntry("client-cert-$index", certificate) - } - } - - val trustManagerFactory = - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(keyStore) - return trustManagerFactory - } +// private fun buildTrustManager(): TrustManagerFactory { +// val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } +// +// clientCertificatesFolder.toFile().listFiles { file -> file.extension == "crt" } +// ?.forEachIndexed { index, certFile -> +// certFile.inputStream().use { inputStream -> +// val certificate = +// CertificateFactory.getInstance("X.509").generateCertificate(inputStream) +// keyStore.setCertificateEntry("client-cert-$index", certificate) +// } +// } +// +// val trustManagerFactory = +// TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) +// trustManagerFactory.init(keyStore) +// return trustManagerFactory +// } } \ No newline at end of file diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerRunningPacketListenerImpl.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerRunningPacketListenerImpl.kt index 94af503e..8b733300 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerRunningPacketListenerImpl.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/network/ServerRunningPacketListenerImpl.kt @@ -279,6 +279,10 @@ class ServerRunningPacketListenerImpl( ) } + override fun handleUpdateAFKState(packet: ServerboundUpdateAFKState) { + withPlayer(packet.uuid) { updateAfkStatus(packet.isAfk) } + } + override fun handlePacket(packet: NettyPacket) { val listeners = NettyListenerRegistry.getListeners(packet.javaClass) ?: return if (listeners.isEmpty()) return diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/CloudPlayerPlaytimeManager.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/CloudPlayerPlaytimeManager.kt new file mode 100644 index 00000000..a3cf4a3f --- /dev/null +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/CloudPlayerPlaytimeManager.kt @@ -0,0 +1,175 @@ +package dev.slne.surf.cloud.standalone.player + +import dev.slne.surf.cloud.api.common.event.player.connection.CloudPlayerDisconnectFromNetworkEvent +import dev.slne.surf.cloud.api.common.util.mutableObject2ObjectMapOf +import dev.slne.surf.cloud.api.common.util.mutableObjectListOf +import dev.slne.surf.cloud.core.common.coroutines.PlayerPlaytimeScope +import dev.slne.surf.cloud.standalone.player.db.exposed.CloudPlayerService +import dev.slne.surf.surfapi.core.api.util.logger +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.springframework.beans.factory.DisposableBean +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.ZonedDateTime +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +@Component +class CloudPlayerPlaytimeManager(private val service: CloudPlayerService) : DisposableBean { + private val log = logger() + + /** In-memory map of active sessions: player UUID -> session data */ + private val sessions = mutableObject2ObjectMapOf() + + /** Protects read/write access to 'sessions' **/ + private val sessionsMutex = Mutex() + + /** + * Called every second to either increment existing session time or start a new session. + * Database inserts happen outside the lock to avoid blocking other tasks. + */ + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) + suspend fun playtimeTask() { + val toCreate = mutableObjectListOf>() + val onlinePlayers = standalonePlayerManagerImpl.getRawOnlinePlayers() + + // Acquire lock briefly to update in-memory sessions + sessionsMutex.withLock { + onlinePlayers.forEach { player -> + val uuid = player.uuid + val server = player.server ?: return@forEach + val serverName = server.name + val category = server.group + + val currentSession = sessions[uuid] + + // If server/category changed or there's no active session, create a new one + if (currentSession == null || currentSession.serverName != serverName || currentSession.category != category) { + // Flush old session if present + if (currentSession != null) { + PlayerPlaytimeScope.launch { partialFlushSession(uuid, currentSession) } + sessions.remove(uuid) + } + + val newSession = PlaytimeSession( + sessionId = null, + serverName = serverName, + category = category, + startTime = ZonedDateTime.now() + ) + + sessions[uuid] = newSession + toCreate += uuid to newSession + } else { + if (player.afk) return@forEach + // Just increment current session + currentSession.accumulatedSeconds++ + } + } + } + + // Insert new sessions into DB outside the lock + PlayerPlaytimeScope.launch { + val createdSessions = toCreate.map { (uuid, session) -> + val dbId = createSessionInDB(uuid, session.serverName, session.category) + Triple(uuid, session, dbId) + } + + // Update sessionId in memory if still valid + sessionsMutex.withLock { + createdSessions.forEach { (uuid, session, dbId) -> + if (sessions[uuid] === session) { + session.sessionId = dbId + } + } + } + } + } + + /** + * Flushes ongoing sessions every 5 minutes to reduce data loss. + */ + @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES) + suspend fun partialFlushAllTask() { + val snapshot = mutableObjectListOf>() + val time = measureTimeMillis { + // Take a snapshot of all sessions that are fully created in DB + sessionsMutex.withLock { + sessions.forEach { (uuid, session) -> + val sessionId = session.sessionId ?: return@forEach + snapshot += Triple(uuid, sessionId, session) + } + } + + // Perform DB updates outside the lock + snapshot.forEach { (uuid, sessionId, session) -> + service.updatePlaytimeInSession(uuid, sessionId, session.accumulatedSeconds) + } + } + + log.atInfo() + .log("Flushed ${snapshot.size} playtime sessions to DB in $time ms") + } + + /** + * On player disconnect, remove session from memory and flush final time to DB. + */ + @EventListener + fun onPlayerDisconnect(event: CloudPlayerDisconnectFromNetworkEvent) { + val uuid = event.player.uuid + PlayerPlaytimeScope.launch { + val session = sessionsMutex.withLock { sessions.remove(uuid) } ?: return@launch + partialFlushSession(uuid, session) + } + } + + /** + * On shutdown, flush all sessions to the database. + */ + override fun destroy() = runBlocking { + log.atInfo().log("Flushing all playtime sessions to DB on shutdown") + val time = measureTimeMillis { + sessionsMutex.withLock { + sessions.forEach { (playerId, session) -> + partialFlushSession(playerId, session) + } + sessions.clear() + } + } + log.atInfo().log("Flushed all playtime sessions to DB in $time ms") + } + + /** + * Creates a DB row for a new session and returns its ID + */ + private suspend fun createSessionInDB( + uuid: UUID, + serverName: String, + category: String + ): Long { + return service.createPlaytimeSession(uuid, serverName, category) + } + + /** + * Updates the DB row with the current accumulated playtime + */ + private suspend fun partialFlushSession(playerId: UUID, session: PlaytimeSession) { + val sessionId = session.sessionId ?: return + service.updatePlaytimeInSession(playerId, sessionId, session.accumulatedSeconds) + } + + suspend fun playtimeSessionFor(uuid: UUID) = sessionsMutex.withLock { sessions[uuid] } + + data class PlaytimeSession( + var sessionId: Long?, + val serverName: String, + val category: String, + val startTime: ZonedDateTime, + var accumulatedSeconds: Long = 0 + ) +} \ No newline at end of file diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/OfflineCloudPlayerImpl.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/OfflineCloudPlayerImpl.kt index 1b06d6d9..41ec358e 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/OfflineCloudPlayerImpl.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/OfflineCloudPlayerImpl.kt @@ -3,8 +3,10 @@ package dev.slne.surf.cloud.standalone.player import dev.slne.surf.cloud.api.common.player.CloudPlayer import dev.slne.surf.cloud.api.common.player.CloudPlayerManager import dev.slne.surf.cloud.api.common.player.name.NameHistory +import dev.slne.surf.cloud.api.common.player.playtime.Playtime import dev.slne.surf.cloud.api.common.server.CloudServer import dev.slne.surf.cloud.core.common.player.CommonOfflineCloudPlayerImpl +import dev.slne.surf.cloud.core.common.player.playtime.PlaytimeImpl import dev.slne.surf.cloud.core.common.util.bean import dev.slne.surf.cloud.standalone.player.db.exposed.CloudPlayerService import dev.slne.surf.cloud.standalone.server.serverManagerImpl @@ -37,9 +39,17 @@ class OfflineCloudPlayerImpl(uuid: UUID) : CommonOfflineCloudPlayerImpl(uuid) { override suspend fun lastSeen(): ZonedDateTime? = player?.lastSeen() ?: service.findLastSeen(uuid) + override suspend fun firstSeen(): ZonedDateTime? = + player?.firstSeen() ?: service.findFirstSeen(uuid) + override suspend fun latestIpAddress(): Inet4Address? = player?.latestIpAddress() ?: service.findLastIpAddress(uuid) + override suspend fun playtime(): Playtime { + return player?.playtime() ?: service.loadPlaytimeEntries(uuid) + .let { if (it.isEmpty()) PlaytimeImpl.EMPTY else PlaytimeImpl(it) } + } + override suspend fun displayName(): Component? { return player?.displayName() ?: serverManagerImpl.requestOfflineDisplayName(uuid) } diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerImpl.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerImpl.kt index 2a31334d..183970f5 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerImpl.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerImpl.kt @@ -4,6 +4,7 @@ import dev.slne.surf.cloud.api.common.netty.packet.NettyPacket import dev.slne.surf.cloud.api.common.player.ConnectionResult import dev.slne.surf.cloud.api.common.player.ConnectionResultEnum import dev.slne.surf.cloud.api.common.player.name.NameHistory +import dev.slne.surf.cloud.api.common.player.playtime.Playtime import dev.slne.surf.cloud.api.common.player.ppdc.PersistentPlayerDataContainer import dev.slne.surf.cloud.api.common.player.teleport.TeleportCause import dev.slne.surf.cloud.api.common.player.teleport.TeleportFlag @@ -13,12 +14,18 @@ import dev.slne.surf.cloud.api.server.server.ServerCommonCloudServer import dev.slne.surf.cloud.core.common.netty.network.protocol.running.* import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundTransferPlayerPacketResponse.Status import dev.slne.surf.cloud.core.common.player.CommonCloudPlayerImpl +import dev.slne.surf.cloud.core.common.player.playtime.PlaytimeEntry +import dev.slne.surf.cloud.core.common.player.playtime.PlaytimeImpl import dev.slne.surf.cloud.core.common.player.ppdc.PersistentPlayerDataContainerImpl import dev.slne.surf.cloud.core.common.util.bean import dev.slne.surf.cloud.standalone.player.db.exposed.CloudPlayerService import dev.slne.surf.cloud.standalone.server.StandaloneCloudServerImpl import dev.slne.surf.cloud.standalone.server.StandaloneProxyCloudServerImpl +import dev.slne.surf.surfapi.core.api.messages.adventure.sendText import dev.slne.surf.surfapi.core.api.util.logger +import dev.slne.surf.surfapi.core.api.util.mutableObjectListOf +import dev.slne.surf.surfapi.core.api.util.toObjectList +import it.unimi.dsi.fastutil.objects.ObjectList import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,9 +43,12 @@ import net.kyori.adventure.title.Title import net.kyori.adventure.title.TitlePart import net.querz.nbt.tag.CompoundTag import java.net.Inet4Address +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit import java.util.* import java.util.concurrent.TimeUnit import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class StandaloneCloudPlayerImpl(uuid: UUID, val name: String, val ip: Inet4Address) : CommonCloudPlayerImpl(uuid) { @@ -46,6 +56,7 @@ class StandaloneCloudPlayerImpl(uuid: UUID, val name: String, val ip: Inet4Addre companion object { private val log = logger() private val service by lazy { bean() } + private val playtimeManager by lazy { bean() } } @Volatile @@ -71,7 +82,14 @@ class StandaloneCloudPlayerImpl(uuid: UUID, val name: String, val ip: Inet4Addre private set private val ppdc = PersistentPlayerDataContainerImpl() + private val ppdcMutex = Mutex() + private var firstSeenCache: ZonedDateTime? = null + + var afk = false + private set + + var sessionStartTime: ZonedDateTime = ZonedDateTime.now() fun savePlayerData(tag: CompoundTag) { if (!ppdc.empty) { @@ -86,6 +104,30 @@ class StandaloneCloudPlayerImpl(uuid: UUID, val name: String, val ip: Inet4Addre } } + override suspend fun isAfk(): Boolean { + return afk + } + + override suspend fun currentSessionDuration(): Duration { + val duration = sessionStartTime.until(ZonedDateTime.now(), ChronoUnit.SECONDS) + return duration.seconds + } + + fun updateAfkStatus(newValue: Boolean) { + if (newValue == afk) return + afk = newValue + + sendText { + appendPrefix() + info("Du bist nun ") + if (afk) { + info("AFK und erhältst keine weiteren Paychecks.") + } else { + info("nicht mehr AFK.") + } + } + } + override suspend fun withPersistentData(block: PersistentPlayerDataContainer.() -> R): R = ppdcMutex.withLock { ppdc.block() @@ -103,10 +145,42 @@ class StandaloneCloudPlayerImpl(uuid: UUID, val name: String, val ip: Inet4Addre return ip } + override suspend fun playtime(): Playtime { + val dbPlaytimes = service.loadPlaytimeEntries(uuid) + val memoryPlaytimes = createMemoryEntriesFromSessions() + dbPlaytimes.removeIf {db -> memoryPlaytimes.any { mem -> db.id == mem.id } } + val allPlaytimes = dbPlaytimes + memoryPlaytimes + + if (allPlaytimes.isEmpty()) { + return PlaytimeImpl.EMPTY + } + + return PlaytimeImpl(allPlaytimes.toObjectList()) + } + + private suspend fun createMemoryEntriesFromSessions(): ObjectList { + val session = playtimeManager.playtimeSessionFor(uuid) ?: return mutableObjectListOf() + return mutableObjectListOf( + PlaytimeEntry( + id = session.sessionId, + category = session.category, + server = session.serverName, + durationSeconds = session.accumulatedSeconds, + createdAt = session.startTime, + ) + ) + } + override suspend fun lastServerRaw(): String { return anyServer.name } + override suspend fun firstSeen(): ZonedDateTime? { + return firstSeenCache ?: service.findFirstSeen(uuid).also { + firstSeenCache = it + } + } + override suspend fun nameHistory(): NameHistory { return service.findNameHistories(uuid) } diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerManagerImpl.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerManagerImpl.kt index cb9a9f98..d3c96e76 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerManagerImpl.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/StandaloneCloudPlayerManagerImpl.kt @@ -19,6 +19,7 @@ import dev.slne.surf.cloud.standalone.server.serverManagerImpl import dev.slne.surf.surfapi.core.api.util.logger import kotlinx.coroutines.* import java.net.Inet4Address +import java.time.ZonedDateTime import java.util.* import kotlin.time.Duration.Companion.minutes @@ -149,6 +150,7 @@ class StandaloneCloudPlayerManagerImpl : CloudPlayerManagerImpl) : AuditableLongEntity(id, CloudPlaye var lastSeen by CloudPlayerTable.lastSeen var lastIpAddress by CloudPlayerTable.lastIpAddress val nameHistories by CloudPlayerNameHistoryEntity referrersOn CloudPlayerNameHistoryTable.player + val playtimes by CloudPlayerPlaytimesEntity referrersOn CloudPlayerPlaytimesTable.player } class CloudPlayerNameHistoryEntity(id: EntityID) : AuditableLongEntity( @@ -23,4 +24,17 @@ class CloudPlayerNameHistoryEntity(id: EntityID) : AuditableLongEntity( var name by CloudPlayerNameHistoryTable.name var player by CloudPlayerEntity referencedOn CloudPlayerNameHistoryTable.player +} + +class CloudPlayerPlaytimesEntity(id: EntityID) : AuditableLongEntity( + id, + CloudPlayerPlaytimesTable +) { + companion object : + AuditableLongEntityClass(CloudPlayerPlaytimesTable) + + var serverName by CloudPlayerPlaytimesTable.serverName + var category by CloudPlayerPlaytimesTable.category + var durationSeconds by CloudPlayerPlaytimesTable.durationSeconds + var player by CloudPlayerEntity referencedOn CloudPlayerPlaytimesTable.player } \ No newline at end of file diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerService.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerService.kt index 1e52f715..e0d819ed 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerService.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerService.kt @@ -1,7 +1,9 @@ package dev.slne.surf.cloud.standalone.player.db.exposed import dev.slne.surf.cloud.api.common.player.name.NameHistoryFactory +import dev.slne.surf.cloud.api.common.util.mutableObjectListOf import dev.slne.surf.cloud.api.server.exposed.service.AbstractExposedDAOService +import dev.slne.surf.cloud.core.common.player.playtime.PlaytimeEntry import dev.slne.surf.cloud.standalone.player.StandaloneCloudPlayerImpl import dev.slne.surf.cloud.standalone.player.name.create import dev.slne.surf.cloud.standalone.server.serverManagerImpl @@ -26,6 +28,7 @@ class CloudPlayerService : AbstractExposedDAOService({ suspend fun findLastServer(uuid: UUID) = find(uuid) { lastServer } suspend fun findLastSeen(uuid: UUID) = find(uuid) { lastSeen } + suspend fun findFirstSeen(uuid: UUID) = find(uuid) { createdAt } suspend fun findLastIpAddress(uuid: UUID) = find(uuid) { lastIpAddress } suspend fun updateOnDisconnect(player: StandaloneCloudPlayerImpl, oldServer: Long?) { @@ -57,4 +60,36 @@ class CloudPlayerService : AbstractExposedDAOService({ } } } + + suspend fun createPlaytimeSession(uuid: UUID, serverName: String, category: String): Long { + var id: Long? = null + update(uuid, createIfMissing = true) { + id = CloudPlayerPlaytimesEntity.new { + this.serverName = serverName + this.category = category + this.player = this@update + }.id.value + } + + return id ?: error("Failed to create playtime session for player $uuid") + } + + suspend fun updatePlaytimeInSession(uuid: UUID, playtimeId: Long, playtimeSeconds: Long) = + withTransaction { + CloudPlayerPlaytimesEntity.findByIdAndUpdate(playtimeId) { + it.durationSeconds = playtimeSeconds + } + } + + suspend fun loadPlaytimeEntries(uuid: UUID) = withTransaction { + find(uuid)?.playtimes?.mapTo(mutableObjectListOf()) { + PlaytimeEntry( + id = it.id.value, + category = it.category, + server = it.serverName, + durationSeconds = it.durationSeconds, + createdAt = it.createdAt, + ) + } ?: mutableObjectListOf() + } } \ No newline at end of file diff --git a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerTables.kt b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerTables.kt index a7645ad8..9df01c42 100644 --- a/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerTables.kt +++ b/surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/db/exposed/CloudPlayerTables.kt @@ -1,11 +1,9 @@ package dev.slne.surf.cloud.standalone.player.db.exposed -import dev.slne.surf.cloud.api.common.config.properties.CloudProperties import dev.slne.surf.cloud.api.server.exposed.columns.charUuid import dev.slne.surf.cloud.api.server.exposed.columns.inet import dev.slne.surf.cloud.api.server.exposed.columns.zonedDateTime import dev.slne.surf.cloud.api.server.exposed.table.AuditableLongIdTable -import org.jetbrains.exposed.dao.id.LongIdTable import java.net.Inet4Address object CloudPlayerTable : AuditableLongIdTable("cloud_player") { @@ -23,4 +21,12 @@ object CloudPlayerTable : AuditableLongIdTable("cloud_player") { object CloudPlayerNameHistoryTable : AuditableLongIdTable("cloud_player_name_history") { val name = char("name", 16) val player = reference("cloud_player_id", CloudPlayerTable) -} \ No newline at end of file +} + +object CloudPlayerPlaytimesTable : AuditableLongIdTable("cloud_player_playtimes") { + val serverName = char("server_name", 255) + val category = varchar("category", 255) + val durationSeconds = long("duration_seconds").default(0) + + val player = reference("cloud_player_id", CloudPlayerTable) +} diff --git a/surf-cloud-standalone/src/main/resources/db/migration/V2__Add_cloud_player_playtimes.sql b/surf-cloud-standalone/src/main/resources/db/migration/V2__Add_cloud_player_playtimes.sql new file mode 100644 index 00000000..b2319498 --- /dev/null +++ b/surf-cloud-standalone/src/main/resources/db/migration/V2__Add_cloud_player_playtimes.sql @@ -0,0 +1,13 @@ +CREATE TABLE cloud_player_playtimes +( + id BIGINT NOT NULL AUTO_INCREMENT, + server_name VARCHAR(255) NOT NULL, + category VARCHAR(255) NOT NULL, + duration_seconds BIGINT NOT NULL DEFAULT 0, + cloud_player_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT FK_CLOUD_PLAYER_PLAYTIMES_ON_CLOUD_PLAYER FOREIGN KEY (cloud_player_id) + REFERENCES cloud_player (id) ON DELETE CASCADE +);