Skip to content

Commit 89c42d8

Browse files
committed
Implement kill counter
1 parent 4192c7f commit 89c42d8

12 files changed

Lines changed: 235 additions & 32 deletions

File tree

client/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ plugins {
88
version = "1.0.0"
99

1010
dependencies {
11+
implementation(projects.common)
12+
implementation(libs.kotlinx.serialization.json)
1113
implementation(libs.jnativehook)
1214
implementation(libs.ktor.client.cio)
1315
implementation(libs.ktor.client.websockets)
16+
implementation(libs.ktor.client.resources)
17+
implementation(libs.ktor.serialization.kotlinx.json)
1418
implementation(libs.kotlin.logging)
1519
implementation(libs.slf4j.simple)
1620
}

client/src/main/kotlin/Main.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ package dev.schlaubi.mastermind
33
import com.github.kwhat.jnativehook.GlobalScreen
44
import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent
55
import com.github.kwhat.jnativehook.keyboard.NativeKeyListener
6+
import dev.schlaubi.gtakiller.common.Event
7+
import dev.schlaubi.gtakiller.common.KillGtaEvent
8+
import dev.schlaubi.gtakiller.common.Route
69
import io.github.oshai.kotlinlogging.KotlinLogging
710
import io.ktor.client.*
11+
import io.ktor.client.plugins.resources.*
812
import io.ktor.client.plugins.websocket.*
9-
import io.ktor.websocket.*
13+
import io.ktor.http.*
14+
import io.ktor.serialization.kotlinx.*
1015
import kotlinx.coroutines.*
16+
import kotlinx.serialization.json.Json
1117
import java.util.concurrent.Executors
1218
import kotlin.jvm.optionals.getOrNull
1319
import kotlin.time.Duration.Companion.seconds
@@ -22,19 +28,27 @@ private val LoomDispatcher = Executors
2228
private val client = HttpClient {
2329
install(WebSockets) {
2430
pingInterval = 2.seconds
31+
contentConverter = KotlinxWebsocketSerializationConverter(Json)
2532
}
33+
install(Resources)
2634
}
2735

2836
private fun CoroutineScope.connect() {
2937
launch {
30-
session = client.webSocketSession("wss://ks.haxis.me")
38+
session = client.webSocketSession {
39+
url {
40+
takeFrom("ws://localhost:8080")
41+
client.href(Route.Events(), this)
42+
}
43+
44+
headers.append("X-Username", System.getProperty("user.name"))
45+
}
3146

3247
LOG.info { "Connection established" }
33-
for (frame in session.incoming) {
48+
while (isActive) {
49+
val frame = session.receiveDeserialized<Event>()
3450
LOG.trace { "Got frame: $frame" }
35-
val incoming = (frame as? Frame.Text)?.readText() ?: continue
36-
LOG.debug { "Got frame text: $incoming" }
37-
if (incoming == "KILL_GTA") {
51+
if (frame is KillGtaEvent) {
3852
kill()
3953
}
4054
}
@@ -74,7 +88,7 @@ private fun kill() {
7488
}
7589
}
7690

77-
private suspend fun report() = session.outgoing.send(Frame.Text("KILL_GTA"))
91+
private suspend fun report() = session.sendSerialized<Event>(KillGtaEvent)
7892

7993
private suspend fun reportAndKill() {
8094
kill()

common/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
plugins {
2+
id("buildsrc.convention.kotlin-jvm")
3+
alias(libs.plugins.kotlin.serialization)
4+
}
5+
6+
dependencies {
7+
api(libs.kotlinx.serialization.json)
8+
api(libs.kotlinx.datetime)
9+
api(libs.ktor.resources)
10+
}

common/src/main/kotlin/Events.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.schlaubi.gtakiller.common
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
sealed interface Event
8+
9+
@SerialName("kill")
10+
@Serializable
11+
data object KillGtaEvent : Event
12+
13+
@SerialName("update_kill_counter")
14+
@Serializable
15+
data class UpdateKillCounterEvent(val killCount: Int, val kill: Kill) : Event

common/src/main/kotlin/Routes.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package dev.schlaubi.gtakiller.common
2+
3+
import io.ktor.resources.*
4+
5+
class Route {
6+
@Resource("events")
7+
class Events
8+
9+
@Resource("status")
10+
class Status
11+
}

common/src/main/kotlin/Status.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package dev.schlaubi.gtakiller.common
2+
3+
import kotlinx.datetime.Instant
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class Status(
8+
val kills: List<Kill>,
9+
val count: Int
10+
) {
11+
val scoreboard: List<ScoreboardEntry>
12+
get() = kills
13+
.groupBy { it.user }
14+
.map { ScoreboardEntry(it.key, it.value.size) }
15+
.sortedByDescending { it.kills }
16+
17+
}
18+
19+
@Serializable
20+
data class ScoreboardEntry(val user: String, val kills: Int)
21+
22+
@Serializable
23+
data class Kill(
24+
val timestamp: Instant,
25+
val user: String
26+
)

gradle/libs.versions.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
11
[versions]
22
kotlin = "2.1.10"
33
ktor = "3.1.0"
4+
kotlinx-serialization = "1.8.0"
45

56
[libraries]
67
kotlinGradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
78
jnativehook = { group = "com.github.kwhat", name = "jnativehook", version = "2.2.2" }
89

10+
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
11+
kotlinx-serialization-json-io = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json-io", version.ref = "kotlinx-serialization" }
12+
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.6.2" }
13+
kotlinx-io-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-io-core", version = "0.6.0" }
14+
15+
ktor-resources = { group = "io.ktor", name = "ktor-resources", version.ref = "ktor" }
916
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
17+
ktor-client-resources = { group = "io.ktor", name = "ktor-client-resources", version.ref = "ktor" }
1018
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
1119
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
1220
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
1321
ktor-server-forwarded-header = { group = "io.ktor", name = "ktor-server-forwarded-header", version.ref = "ktor" }
22+
ktor-server-resources = { group = "io.ktor", name = "ktor-server-resources", version.ref = "ktor" }
23+
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
24+
25+
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
1426

1527
kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version = "7.0.4" }
1628
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.16" }
29+
30+
[plugins]
31+
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

server/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ plugins {
44
}
55

66
dependencies {
7+
implementation(projects.common)
8+
implementation(libs.kotlinx.serialization.json.io)
9+
implementation(libs.kotlinx.io.core)
710
implementation(libs.ktor.server.netty)
811
implementation(libs.ktor.server.websockets)
912
implementation(libs.ktor.server.forwarded.header)
13+
implementation(libs.ktor.server.resources)
14+
implementation(libs.ktor.serialization.kotlinx.json)
15+
implementation(libs.ktor.server.content.negotiation)
1016
implementation(libs.kotlin.logging)
1117
implementation(libs.slf4j.simple)
1218
}

server/src/main/kotlin/Server.kt

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,87 @@
11
package dev.schlaubi.gtakiller
22

3+
import dev.schlaubi.gtakiller.common.Kill
4+
import dev.schlaubi.gtakiller.common.KillGtaEvent
5+
import dev.schlaubi.gtakiller.common.Route
6+
import dev.schlaubi.gtakiller.common.UpdateKillCounterEvent
37
import io.github.oshai.kotlinlogging.KotlinLogging
8+
import io.ktor.serialization.kotlinx.*
9+
import io.ktor.serialization.kotlinx.json.*
410
import io.ktor.server.application.*
511
import io.ktor.server.engine.*
612
import io.ktor.server.netty.*
713
import io.ktor.server.plugins.*
14+
import io.ktor.server.plugins.contentnegotiation.*
815
import io.ktor.server.plugins.forwardedheaders.*
16+
import io.ktor.server.resources.*
17+
import io.ktor.server.response.*
918
import io.ktor.server.routing.*
1019
import io.ktor.server.websocket.*
11-
import io.ktor.websocket.*
20+
import kotlinx.coroutines.isActive
21+
import kotlinx.datetime.Clock
22+
import kotlinx.datetime.Instant
23+
import kotlinx.serialization.json.Json
24+
import kotlin.time.Duration.Companion.seconds
1225

1326
private val LOG = KotlinLogging.logger { }
1427

28+
private var lastKill: Instant? = null
29+
1530
fun main() {
1631
val sessions = mutableListOf<DefaultWebSocketServerSession>()
1732

1833
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
19-
install(WebSockets)
34+
install(WebSockets) {
35+
contentConverter = KotlinxWebsocketSerializationConverter(Json)
36+
}
37+
install(Resources)
38+
install(ContentNegotiation) {
39+
json()
40+
}
2041
install(XForwardedHeaders)
2142

2243
routing {
23-
webSocket {
24-
sessions += this
44+
resource<Route.Events> {
45+
webSocket {
46+
sessions += this
2547

26-
LOG.info { "Got session from: ${call.request.origin.remoteHost}" }
48+
val name = call.request.headers["X-Username"] ?: "Anonymous"
2749

28-
for (frame in incoming) {
29-
LOG.debug { "GOT FRAME: $frame" }
50+
LOG.info { "Got session from: ${call.request.origin.remoteHost}" }
3051

31-
if (frame is Frame.Text) {
32-
val text = frame.readText()
33-
LOG.debug { "Got frame text: $text" }
52+
while (isActive) {
53+
val event = receiveEvent()
54+
LOG.debug { "GOT FRAME: $event" }
3455

3556
sessions.forEach {
36-
it.send(text)
57+
it.send(event)
58+
}
59+
60+
if (event is KillGtaEvent) {
61+
val kill = Kill(Clock.System.now(), name)
62+
val timeSinceLastKill = Clock.System.now() - (lastKill ?: Instant.DISTANT_PAST)
63+
if (timeSinceLastKill >= 10.seconds) {
64+
writeStats(
65+
stats.copy(
66+
stats.kills + kill,
67+
count = stats.count + 1
68+
)
69+
)
70+
}
71+
sessions.forEach {
72+
it.send(UpdateKillCounterEvent(stats.count, kill))
73+
}
3774
}
3875
}
39-
}
4076

41-
sessions -= this
77+
sessions -= this
78+
79+
LOG.info { "Lost session: ${call.request.origin.remoteHost}" }
80+
}
81+
}
4282

43-
LOG.info { "Lost session: ${call.request.origin.remoteHost}" }
83+
get<Route.Status> {
84+
call.respond(stats)
4485
}
4586
}
4687
}.start(wait = true)

server/src/main/kotlin/Storage.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
@file:OptIn(ExperimentalSerializationApi::class)
2+
3+
package dev.schlaubi.gtakiller
4+
5+
import dev.schlaubi.gtakiller.common.Status
6+
import kotlinx.coroutines.sync.Mutex
7+
import kotlinx.coroutines.sync.withLock
8+
import kotlinx.io.buffered
9+
import kotlinx.io.files.Path
10+
import kotlinx.io.files.SystemFileSystem
11+
import kotlinx.serialization.ExperimentalSerializationApi
12+
import kotlinx.serialization.json.Json
13+
import kotlinx.serialization.json.io.decodeFromSource
14+
import kotlinx.serialization.json.io.encodeToSink
15+
16+
private val mutex = Mutex()
17+
18+
var stats = readStats()
19+
private set
20+
21+
private val file get() = Path("store/stats.json")
22+
23+
private val fs get() = SystemFileSystem
24+
25+
private fun readStats(): Status {
26+
if (!fs.exists(file)) {
27+
return Status(emptyList(), 0)
28+
}
29+
30+
return fs.source(file).buffered().use {
31+
Json.decodeFromSource(it)
32+
}
33+
}
34+
35+
suspend fun writeStats(status: Status) {
36+
stats = status
37+
38+
if (!fs.exists(file.parent!!)) {
39+
fs.createDirectories(file.parent!!)
40+
}
41+
42+
mutex.withLock {
43+
fs.sink(file).buffered().use {
44+
Json.encodeToSink(status, it)
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)