Skip to content
This repository was archived by the owner on Dec 10, 2025. It is now read-only.

Commit 679d2a7

Browse files
committed
feat: implement playtime tracking and management for players
1 parent 9cbd485 commit 679d2a7

File tree

15 files changed

+205
-18
lines changed

15 files changed

+205
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Docs: [https://surf-cloud-docs.netlify.app/](https://surf-cloud-docs.netlify.app
44

55
![logo](https://github.com/SLNE-Development/assets/blob/master/logos/surf-cloud/surf-cloud-logo-bg-removed.png?raw=true)
66

7+
https://chatgpt.com/g/g-67f7a61dc2208191bea4663d0771d6dd-flyway-migration-generator
78

89
# TODO
910
## IntelliJ Plugin

surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/OfflineCloudPlayer.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.slne.surf.cloud.api.common.player
22

33
import dev.slne.surf.cloud.api.common.player.name.NameHistory
4+
import dev.slne.surf.cloud.api.common.player.playtime.Playtime
45
import dev.slne.surf.cloud.api.common.server.CloudServer
56
import net.kyori.adventure.text.Component
67
import java.net.Inet4Address
@@ -21,6 +22,7 @@ interface OfflineCloudPlayer {
2122
suspend fun latestIpAddress(): Inet4Address?
2223

2324
suspend fun playedBefore(): Boolean
25+
suspend fun playtime(): Playtime
2426

2527
/**
2628
* Returns the online player instance if the player is currently connected.

surf-cloud-api/surf-cloud-api-common/src/main/kotlin/dev/slne/surf/cloud/api/common/player/playtime/Playtime.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.slne.surf.cloud.api.common.player.playtime
22

33
import dev.slne.surf.cloud.api.common.server.CloudServer
4+
import io.netty.buffer.ByteBuf
45
import it.unimi.dsi.fastutil.objects.Object2ObjectMap
56
import it.unimi.dsi.fastutil.objects.ObjectList
67
import it.unimi.dsi.fastutil.objects.ObjectSet
@@ -97,6 +98,8 @@ interface Playtime {
9798
*/
9899
fun playtimesPerCategory(since: ZonedDateTime? = null): Object2ObjectMap<String, Duration>
99100

101+
fun playtimePerCategoryPerServer(since: ZonedDateTime? = null): Object2ObjectMap<String, Object2ObjectMap<String, Duration>>
102+
100103
/**
101104
* Returns the average playtime per server, optionally filtered by category and start time.
102105
*
@@ -174,4 +177,6 @@ interface Playtime {
174177
* @return An [ObjectList] of pairs, each containing a category name and its corresponding playtime duration.
175178
*/
176179
fun topCategories(limit: Int = 5, since: ZonedDateTime? = null): ObjectList<Pair<String, Duration>>
180+
181+
fun writeToByteBuf(buf: ByteBuf)
177182
}

surf-cloud-bukkit/src/main/kotlin/dev/slne/surf/cloud/bukkit/BukkitMain.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import dev.slne.surf.cloud.api.common.server.CloudServerManager
1616
import dev.slne.surf.cloud.bukkit.player.BukkitClientCloudPlayerImpl
1717
import dev.slne.surf.cloud.core.common.handleEventuallyFatalError
1818
import dev.slne.surf.surfapi.bukkit.api.event.listen
19+
import dev.slne.surf.surfapi.core.api.messages.Colors
1920
import dev.slne.surf.surfapi.core.api.messages.CommonComponents
2021
import dev.slne.surf.surfapi.core.api.messages.adventure.buildText
2122
import dev.slne.surf.surfapi.core.api.messages.adventure.sendText
@@ -279,6 +280,49 @@ class BukkitMain : SuspendingJavaPlugin() {
279280
sender.sendPlainMessage("Test packet sent")
280281
}
281282
}
283+
284+
commandAPICommand("playtime") {
285+
offlinePlayerArgument("player")
286+
anyExecutor { sender, args ->
287+
val player: OfflinePlayer by args
288+
val cloudPlayer = player.toCloudOfflinePlayer()
289+
launch {
290+
val playtime = cloudPlayer.playtime()
291+
val complete = playtime.sumPlaytimes()
292+
val playtimeMap = playtime.playtimePerCategoryPerServer()
293+
294+
sender.sendText {
295+
appendPrefix()
296+
info("Playtime for player ${player.name} (${player.uniqueId})")
297+
appendNewPrefixedLine()
298+
appendNewPrefixedLine {
299+
variableKey("Total")
300+
spacer(": ")
301+
variableValue(complete.toString())
302+
}
303+
appendNewPrefixedLine()
304+
for ((group, groupServer) in playtimeMap) {
305+
appendNewPrefixedLine {
306+
spacer("- ")
307+
variableKey(group)
308+
spacer(": ")
309+
variableValue(playtime.sumByCategory(group).toString())
310+
311+
for ((serverName, playtime) in groupServer) {
312+
appendNewPrefixedLine {
313+
text(" ")
314+
variableKey(serverName)
315+
spacer(": ")
316+
variableValue(playtime.toString())
317+
}
318+
}
319+
appendNewPrefixedLine()
320+
}
321+
}
322+
}
323+
}
324+
}
325+
}
282326
}
283327

284328
@OptIn(ExperimentalContracts::class)

surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/ClientCloudPlayerImpl.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dev.slne.surf.cloud.api.client.netty.packet.fireAndForget
66
import dev.slne.surf.cloud.api.common.netty.packet.DEFAULT_URGENT_TIMEOUT
77
import dev.slne.surf.cloud.api.common.player.ConnectionResult
88
import dev.slne.surf.cloud.api.common.player.name.NameHistory
9+
import dev.slne.surf.cloud.api.common.player.playtime.Playtime
910
import dev.slne.surf.cloud.api.common.player.ppdc.PersistentPlayerDataContainer
1011
import dev.slne.surf.cloud.api.common.player.teleport.TeleportCause
1112
import dev.slne.surf.cloud.api.common.player.teleport.TeleportFlag
@@ -14,11 +15,7 @@ import dev.slne.surf.cloud.api.common.server.CloudServer
1415
import dev.slne.surf.cloud.core.client.util.luckperms
1516
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.*
1617
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket.DataRequestType
17-
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.FirstSeen
18-
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.IpAddress
19-
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.LastServer
20-
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.Name
21-
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.NameHistory as NameHistoryResponse
18+
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.*
2219
import dev.slne.surf.cloud.core.common.player.CommonCloudPlayerImpl
2320
import dev.slne.surf.cloud.core.common.player.ppdc.PersistentPlayerDataContainerImpl
2421
import dev.slne.surf.surfapi.core.api.messages.adventure.getPointer
@@ -40,6 +37,7 @@ import java.net.Inet4Address
4037
import java.time.ZonedDateTime
4138
import java.util.*
4239
import kotlin.time.Duration
40+
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.NameHistory as NameHistoryResponse
4341

4442
abstract class ClientCloudPlayerImpl<PlatformPlayer : Audience>(uuid: UUID) :
4543
CommonCloudPlayerImpl(uuid) {
@@ -102,6 +100,10 @@ abstract class ClientCloudPlayerImpl<PlatformPlayer : Audience>(uuid: UUID) :
102100
?: error("Failed to get display name (probably timed out)")
103101
}
104102

103+
override suspend fun playtime(): Playtime {
104+
return request<ServerboundRequestPlayerDataResponse.Playtime>(DataRequestType.PLAYTIME).playtime
105+
}
106+
105107
override suspend fun name(): String {
106108
val localName = audience?.getPointer(Identity.NAME)
107109
if (localName != null) {
@@ -324,7 +326,7 @@ abstract class ClientCloudPlayerImpl<PlatformPlayer : Audience>(uuid: UUID) :
324326
return block(luckperms.getPlayerAdapter(platformClass))
325327
}
326328

327-
private suspend inline fun <reified T : ServerboundRequestPlayerDataResponse.DataResponse> request(
329+
private suspend inline fun <reified T : DataResponse> request(
328330
type: DataRequestType
329331
): T {
330332
val response = ServerboundRequestPlayerDataPacket(uuid, type).fireAndAwaitOrThrow().data

surf-cloud-core/surf-cloud-core-client/src/main/kotlin/dev/slne/surf/cloud/core/client/player/OfflineCloudPlayerImpl.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dev.slne.surf.cloud.core.client.player
22

33
import dev.slne.surf.cloud.api.client.netty.packet.fireAndAwaitOrThrow
44
import dev.slne.surf.cloud.api.common.player.name.NameHistory
5+
import dev.slne.surf.cloud.api.common.player.playtime.Playtime
56
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket
67
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket.DataRequestType
78
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.getGenericValue
@@ -40,6 +41,10 @@ class OfflineCloudPlayerImpl(uuid: UUID) : CommonOfflineCloudPlayerImpl(uuid) {
4041
return request(DataRequestType.NAME)
4142
}
4243

44+
override suspend fun playtime(): Playtime {
45+
return request(DataRequestType.PLAYTIME)
46+
}
47+
4348
override suspend fun <R> getLuckpermsMetaData(
4449
key: String,
4550
transformer: (String) -> R

surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/netty/network/protocol/running/ServerboundRequestPlayerDataPacket.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import dev.slne.surf.cloud.api.common.netty.protocol.buffer.readEnum
1111
import dev.slne.surf.cloud.api.common.player.OfflineCloudPlayer
1212
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataPacket.DataRequestType
1313
import dev.slne.surf.cloud.core.common.netty.network.protocol.running.ServerboundRequestPlayerDataResponse.*
14+
import dev.slne.surf.cloud.core.common.player.playtime.PlaytimeImpl
1415
import net.kyori.adventure.text.Component
1516
import java.net.Inet4Address
1617
import java.time.ZonedDateTime
1718
import java.util.*
1819
import dev.slne.surf.cloud.api.common.player.name.NameHistory as ApiNameHistory
20+
import dev.slne.surf.cloud.api.common.player.playtime.Playtime as ApiPlaytime
1921

2022
@SurfNettyPacket("cloud:request:player_data", PacketFlow.SERVERBOUND)
2123
class ServerboundRequestPlayerDataPacket(val uuid: UUID, val type: DataRequestType) :
@@ -74,6 +76,11 @@ class ServerboundRequestPlayerDataPacket(val uuid: UUID, val type: DataRequestTy
7476
override suspend fun readData(player: OfflineCloudPlayer): DataResponse {
7577
return NameHistory(player.nameHistory())
7678
}
79+
},
80+
PLAYTIME(::Playtime) {
81+
override suspend fun readData(player: OfflineCloudPlayer): DataResponse {
82+
return Playtime(player.playtime())
83+
}
7784
};
7885

7986
abstract suspend fun readData(player: OfflineCloudPlayer): DataResponse
@@ -159,6 +166,14 @@ class ServerboundRequestPlayerDataResponse(val data: DataResponse) : ResponseNet
159166
history.writeToByteBuf(buf)
160167
}
161168
}
169+
170+
class Playtime(val playtime: ApiPlaytime) : DataResponse(DataRequestType.PLAYTIME) {
171+
constructor(buf: SurfByteBuf) : this(PlaytimeImpl.readFromByteBuf(buf))
172+
173+
override fun write(buf: SurfByteBuf) {
174+
playtime.writeToByteBuf(buf)
175+
}
176+
}
162177
}
163178

164179
inline fun <reified T> DataResponse.getGenericValue(): T = when (this) {
@@ -169,5 +184,6 @@ inline fun <reified T> DataResponse.getGenericValue(): T = when (this) {
169184
is DisplayName -> check(T::class == Component::class) { "Expected Component" }.let { displayName as T }
170185
is Name -> check(T::class == String::class) { "Expected String" }.let { name as T }
171186
is NameHistory -> check(T::class == ApiNameHistory::class) { "Expected ApiNameHistory" }.let { history as T }
187+
is Playtime -> check(T::class == ApiPlaytime::class) { "Expected ApiPlaytime" }.let { playtime as T }
172188
else -> error("Unknown DataResponse type: ${this::class.simpleName}")
173189
}

surf-cloud-core/surf-cloud-core-common/src/main/kotlin/dev/slne/surf/cloud/core/common/player/playtime/PlaytimeImpl.kt

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package dev.slne.surf.cloud.core.common.player.playtime
22

3+
import dev.slne.surf.cloud.api.common.netty.protocol.buffer.*
34
import dev.slne.surf.cloud.api.common.player.playtime.Playtime
4-
import dev.slne.surf.cloud.api.common.util.mutableObject2ObjectMapOf
5-
import dev.slne.surf.cloud.api.common.util.mutableObjectSetOf
6-
import dev.slne.surf.cloud.api.common.util.toObjectList
5+
import dev.slne.surf.cloud.api.common.util.*
6+
import io.netty.buffer.ByteBuf
77
import it.unimi.dsi.fastutil.objects.Object2ObjectMap
88
import it.unimi.dsi.fastutil.objects.ObjectList
99
import it.unimi.dsi.fastutil.objects.ObjectSet
@@ -75,6 +75,17 @@ class PlaytimeImpl(private val entries: ObjectList<PlaytimeEntry>) : Playtime {
7575
group.sumOf { it.durationSeconds }.seconds
7676
}
7777

78+
override fun playtimePerCategoryPerServer(since: ZonedDateTime?): Object2ObjectMap<String, Object2ObjectMap<String, Duration>> =
79+
entries.filter { since == null || it.createdAt.isAfter(since) }
80+
.groupBy { it.category }
81+
.mapValuesTo(mutableObject2ObjectMapOf()) { (_, groupEntries) ->
82+
groupEntries.groupBy { it.server }
83+
.mapValuesTo(mutableObject2ObjectMapOf()) { (_, serverEntries) ->
84+
serverEntries.sumOf { it.durationSeconds }.seconds
85+
}
86+
}
87+
88+
7889
override fun averagePlaytimePerServer(
7990
category: String?,
8091
since: ZonedDateTime?
@@ -133,6 +144,22 @@ class PlaytimeImpl(private val entries: ObjectList<PlaytimeEntry>) : Playtime {
133144
.sortedByDescending { it.second }
134145
.take(limit)
135146
.toObjectList()
147+
148+
override fun writeToByteBuf(buf: ByteBuf) {
149+
buf.writeCollection(entries) { buf, entry -> entry.writeToByteBuf(buf) }
150+
}
151+
152+
companion object {
153+
val EMPTY = PlaytimeImpl(objectListOf())
154+
155+
fun readFromByteBuf(buf: ByteBuf): PlaytimeImpl {
156+
val entries = buf.readCollection(
157+
{ mutableObjectListOf(it) },
158+
{ PlaytimeEntry.readFromByteBuf(it) }
159+
)
160+
return PlaytimeImpl(entries)
161+
}
162+
}
136163
}
137164

138165
/**
@@ -159,8 +186,28 @@ private fun floorToInterval(time: ZonedDateTime, interval: Duration): ZonedDateT
159186
*/
160187
@Serializable
161188
data class PlaytimeEntry(
189+
val id: Long?,
162190
val category: String,
163191
val server: String,
164192
val durationSeconds: Long,
165193
val createdAt: @Contextual ZonedDateTime,
166-
)
194+
) {
195+
fun writeToByteBuf(buf: ByteBuf) {
196+
buf.writeNullableLong(id)
197+
buf.writeUtf(category)
198+
buf.writeUtf(server)
199+
buf.writeLong(durationSeconds)
200+
buf.writeZonedDateTime(createdAt)
201+
}
202+
203+
companion object {
204+
fun readFromByteBuf(buf: ByteBuf): PlaytimeEntry {
205+
val id = buf.readNullableLong()
206+
val category = buf.readUtf()
207+
val server = buf.readUtf()
208+
val durationSeconds = buf.readLong()
209+
val createdAt = buf.readZonedDateTime()
210+
return PlaytimeEntry(id, category, server, durationSeconds, createdAt)
211+
}
212+
}
213+
}

surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/netty/server/connection/ServerConnectionListener.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package dev.slne.surf.cloud.standalone.netty.server.connection
22

33
import dev.slne.surf.cloud.api.common.netty.network.protocol.PacketFlow
44
import dev.slne.surf.cloud.api.common.netty.packet.NettyPacket
5-
import dev.slne.surf.cloud.api.common.util.*
5+
import dev.slne.surf.cloud.api.common.util.DefaultUncaughtExceptionHandlerWithName
6+
import dev.slne.surf.cloud.api.common.util.mutableObjectListOf
67
import dev.slne.surf.cloud.api.common.util.netty.suspend
8+
import dev.slne.surf.cloud.api.common.util.synchronize
9+
import dev.slne.surf.cloud.api.common.util.threadFactory
710
import dev.slne.surf.cloud.core.common.config.cloudConfig
811
import dev.slne.surf.cloud.core.common.coroutines.ConnectionManagementScope
912
import dev.slne.surf.cloud.core.common.netty.network.ConnectionImpl
@@ -18,11 +21,9 @@ import dev.slne.surf.surfapi.core.api.util.logger
1821
import io.netty.bootstrap.ServerBootstrap
1922
import io.netty.channel.*
2023
import io.netty.channel.epoll.Epoll
21-
import io.netty.channel.epoll.EpollEventLoopGroup
2224
import io.netty.channel.epoll.EpollIoHandler
2325
import io.netty.channel.epoll.EpollServerDomainSocketChannel
2426
import io.netty.channel.epoll.EpollServerSocketChannel
25-
import io.netty.channel.nio.NioEventLoopGroup
2627
import io.netty.channel.nio.NioIoHandler
2728
import io.netty.channel.socket.nio.NioServerSocketChannel
2829
import io.netty.channel.unix.DomainSocketAddress

surf-cloud-standalone/src/main/kotlin/dev/slne/surf/cloud/standalone/player/CloudPlayerPlaytimeManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ class CloudPlayerPlaytimeManager(private val service: CloudPlayerService) : Disp
162162
service.updatePlaytimeInSession(playerId, sessionId, session.accumulatedSeconds)
163163
}
164164

165+
suspend fun playtimeSessionFor(uuid: UUID) = sessionsMutex.withLock { sessions[uuid] }
166+
165167
data class PlaytimeSession(
166168
var sessionId: Long?,
167169
val serverName: String,

0 commit comments

Comments
 (0)