Archived
1
0
Fork 0

Add ServerListInfoRequestEvent, switch from Gson to Moshi and fix tests

This commit is contained in:
Moritz Ruth 2020-08-14 12:40:14 +02:00
parent 81a39c2e96
commit 7dd7bd9e6c
No known key found for this signature in database
GPG key ID: AFD57E23E753841B
19 changed files with 229 additions and 94 deletions

View file

@ -1,5 +1,6 @@
plugins {
kotlin("jvm")
kotlin("kapt")
}
group = rootProject.group
@ -13,13 +14,22 @@ repositories {
val spekVersion = "2.0.12"
dependencies {
// Kotlin
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
implementation("com.google.code.gson:gson:2.8.6")
api("org.slf4j:slf4j-api:1.7.30")
api("io.netty:netty-buffer:4.1.50.Final")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8")
// JSON
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.9.3")
api("com.squareup.moshi:moshi:1.9.3")
// Logging
api("org.slf4j:slf4j-api:1.7.30")
// Netty
api("io.netty:netty-buffer:4.1.50.Final")
// Testing
testImplementation("io.strikt:strikt-core:0.26.1")
testImplementation("org.spekframework.spek2:spek-dsl-jvm:$spekVersion")
testRuntimeOnly("org.spekframework.spek2:spek-runner-junit5:$spekVersion")

View file

@ -1,5 +1,7 @@
package space.blokk
import com.squareup.moshi.Moshi
import space.blokk.chat.ChatComponent
import space.blokk.events.EventTargetGroup
import space.blokk.net.Session
import space.blokk.server.Server
@ -18,4 +20,9 @@ object Blokk: BlokkProvider {
private var provider: BlokkProvider? = null
override val server get() = provider!!.server
override val sessions get() = provider!!.sessions
val json = Moshi.Builder()
.add(ChatComponent.Adapter)
.add(ChatComponent.Color.Adapter)
.build()!!
}

View file

@ -1,23 +1,89 @@
package space.blokk.chat
import com.squareup.moshi.*
import space.blokk.Blokk
import kotlin.reflect.KClass
sealed class ChatComponent {
abstract val extra: ChatComponent?
abstract val bold: Boolean
abstract val italic: Boolean
abstract val underlined: Boolean
abstract val strikethrough: Boolean
abstract val obfuscated: Boolean
abstract val color: Color?
/**
* Cannot be an empty list. Use null instead.
*/
abstract val extra: List<ChatComponent>?
init {
@Suppress("LeakingThis")
if (extra?.isEmpty() == true) throw IllegalArgumentException("extra cannot be an empty list. Use null instead.")
}
fun getExtraTypes(): List<KClass<out ChatComponent>> {
val types = mutableListOf<KClass<out ChatComponent>>()
var current = extra
while (current != null) {
types.add(current::class)
current = current.extra
val types = mutableSetOf<KClass<out ChatComponent>>()
val extras = extra?.toMutableList() ?: return emptyList()
while (extras.isNotEmpty()) {
extras.toList().forEach {
types.add(it::class)
extras.remove(it)
it.extra?.let { extra -> extras.addAll(extra) }
}
}
return types.toList()
}
enum class Color() {
BLACK,
DARK_BLUE,
DARK_GREEN,
DARK_AQUA,
DARK_RED,
DARK_PURPLE,
GOLD,
GRAY,
DARK_GRAY,
BLUE,
GREEN,
AQUA,
RED,
LIGHT_PURPLE,
YELLOW,
WHITE;
object Adapter {
@ToJson fun toJson(value: Color) = value.name.toLowerCase()
@FromJson fun fromJson(value: String) = valueOf(value.toUpperCase())
}
}
object Adapter {
@FromJson fun fromJson(reader: JsonReader): ChatComponent? {
throw UnsupportedOperationException("ChatComponent cannot be deserialized.")
}
@ToJson fun toJson(writer: JsonWriter, value: ChatComponent?) {
@Suppress("UNCHECKED_CAST")
if (value == null) writer.nullValue()
else (Blokk.json.adapter(value::class.java) as JsonAdapter<ChatComponent>).toJson(writer, value)
}
}
}
data class TextComponent(val text: String, override val extra: ChatComponent? = null): ChatComponent() {
@JsonClass(generateAdapter = true)
data class TextComponent(
val text: String,
override val bold: Boolean = false,
override val italic: Boolean = false,
override val underlined: Boolean = false,
override val strikethrough: Boolean = false,
override val obfuscated: Boolean = false,
override val color: Color? = null,
override val extra: List<ChatComponent>? = null
): ChatComponent() {
companion object {
/**
* Creates a new [TextComponent] instance using [text] and returns it.

View file

@ -1,8 +1,8 @@
package space.blokk.chat
/**
* Legacy formatting codes. You should use [ChatComponent][space.blokk.chat.ChatComponent] whenever it's possible, but sometimes
* these codes are required, for example in
* Legacy formatting codes. You should use [ChatComponent][space.blokk.chat.ChatComponent] whenever it's possible,
* but sometimes these codes are required, for example in
* [the name of a player in a server list sample][space.blokk.net.protocols.status.ResponsePacket.Players.SampleEntry.name].
*/
enum class FormattingCode(private val char: Char) {

View file

@ -7,9 +7,9 @@ interface Cancellable {
/**
* Only executes [fn] if [isCancelled][Cancellable.isCancelled] is true.
*/
inline fun Cancellable.ifCancelled(fn: () -> Unit) = if (isCancelled) fn() else Unit
inline fun <T: Cancellable, R> T.ifCancelled(fn: (T) -> R): R? = if (isCancelled) fn(this) else null
/**
* Only executes [fn] if [isCancelled][Cancellable.isCancelled] is false.
*/
inline fun <T> Cancellable.ifNotCancelled(fn: () -> T): T? = if (!isCancelled) fn() else null
inline fun <T: Cancellable, R> T.ifNotCancelled(fn: (T) -> R): R? = if (!isCancelled) fn(this) else null

View file

@ -1,8 +1,7 @@
package space.blokk.events
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import space.blokk.Blokk
import kotlinx.coroutines.async
import space.blokk.plugins.Plugin
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
@ -17,21 +16,15 @@ class EventBus<EventT: Event>(private val eventClass: KClass<EventT>, private va
/**
* Invokes all previously registered event handlers sorted by their priority
* and the order in which they were registered.
*
* @return [event]
*/
suspend fun <T: EventT> emitAndAwait(event: T): T {
handlers.filter { it.eventType.isInstance(event) }.forEach {
scope.launch {
it.fn.callSuspend(it.listener, event)
}
}
suspend fun <T: EventT> emit(event: T): T {
handlers.filter { it.eventType.isInstance(event) }.forEach { it.fn.callSuspend(it.listener, event) }
return event
}
fun <T: EventT> emit(event: T): T {
Blokk.server.scope.launch { emitAndAwait(event) }
return event
}
fun <T: EventT> emitAsync(event: T) = scope.async { emit(event) }
/**
* Registers all [event handlers][EventHandler] in [listener] to be invoked when their corresponding event is emitted.

View file

@ -4,6 +4,6 @@ import space.blokk.events.Cancellable
import space.blokk.net.Session
import space.blokk.net.protocols.IncomingPacket
class SessionPacketReceivedEvent<T: IncomingPacket>(session: Session, var packet: T): SessionEvent(session), Cancellable {
class PacketReceivedEvent<T: IncomingPacket>(session: Session, var packet: T): SessionEvent(session), Cancellable {
override var isCancelled = false
}

View file

@ -4,6 +4,6 @@ import space.blokk.events.Cancellable
import space.blokk.net.Session
import space.blokk.net.protocols.OutgoingPacket
class SessionPacketSendEvent(session: Session, var packet: OutgoingPacket): SessionEvent(session), Cancellable {
class PacketSendEvent(session: Session, var packet: OutgoingPacket): SessionEvent(session), Cancellable {
override var isCancelled = false
}

View file

@ -0,0 +1,12 @@
package space.blokk.net.events
import space.blokk.events.Cancellable
import space.blokk.net.Session
import space.blokk.net.protocols.status.ResponsePacket
class ServerListInfoRequestEvent(
session: Session,
var response: ResponsePacket
): SessionEvent(session), Cancellable {
override var isCancelled = false
}

View file

@ -1,28 +1,42 @@
package space.blokk.net.protocols.status
import com.google.gson.GsonBuilder
import com.squareup.moshi.JsonClass
import io.netty.buffer.ByteBuf
import space.blokk.Blokk
import space.blokk.chat.TextComponent
import space.blokk.net.MinecraftDataTypes
import space.blokk.net.protocols.OutgoingPacket
import space.blokk.net.protocols.OutgoingPacketCompanion
import java.util.*
import space.blokk.utils.toJson
data class ResponsePacket(
val versionName: String,
val protocolVersion: Int,
val description: TextComponent,
val players: Players,
val favicon: String? = null
) : OutgoingPacket() {
constructor(
versionName: String,
protocolVersion: Int,
players: Players,
description: TextComponent,
favicon: ByteArray
): this(versionName, protocolVersion, description, players, Base64.getEncoder().encodeToString(favicon))
/**
* The name of the Minecraft version the server uses.
*/
val versionName: String,
/**
* The number of the protocol version used by the server.
* @see <a href="https://wiki.vg/Protocol_version_numbers">https://wiki.vg/Protocol_version_numbers</a>
*/
val protocolVersion: Int,
/**
* The description of the server. Although this is a [TextComponent],
* [legacy Formatting codes][space.blokk.chat.FormattingCode] must be used.
*/
val description: TextComponent,
/**
* The players shown when hovering over the player count.
*/
val players: Players,
/**
* The favicon of the server. This must be a base64 encoded 64x64 PNG image.
*/
val favicon: String? = null
) : OutgoingPacket() {
init {
if (description.getExtraTypes().find { it != TextComponent::class } != null)
throw Exception("description may only contain instances of TextComponent")
@ -30,27 +44,27 @@ data class ResponsePacket(
override fun encode(dst: ByteBuf) {
with(MinecraftDataTypes) {
dst.writeString(gson.toJson(mapOf(
"version" to mapOf(
"name" to versionName,
"protocol" to protocolVersion
),
"players" to players,
"description" to description,
"favicon" to favicon
dst.writeString(Blokk.json.toJson(mapOf(
"version" to mapOf(
"name" to versionName,
"protocol" to protocolVersion
),
"players" to players,
"description" to description,
"favicon" to favicon?.let { "data:image/png;base64,$it" }
)))
}
}
@JsonClass(generateAdapter = true)
data class Players(val max: Int, val online: Int, val sample: List<SampleEntry>) {
/**
* @param name The name of the player. You can use [FormattingCode](space.blokk.chat.FormattingCode)s
* @param id The UUID of the player as a string. You can use a random UUID as it doesn't get validated by the Minecraft client
*/
@JsonClass(generateAdapter = true)
data class SampleEntry(val name: String, val id: String)
}
companion object : OutgoingPacketCompanion<ResponsePacket>(0x00, ResponsePacket::class) {
private val gson = GsonBuilder().disableHtmlEscaping().create()!!
}
companion object : OutgoingPacketCompanion<ResponsePacket>(0x00, ResponsePacket::class)
}

View file

@ -1,9 +1,12 @@
package space.blokk.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class Logger(name: String) {
private val logger = LoggerFactory.getLogger(name)
private val logger: Logger = LoggerFactory.getLogger(name)
fun error(msg: String, t: Throwable) = logger.error(msg, t)
infix fun error(msg: String) = logger.error(msg)
infix fun info(msg: String) = logger.info(msg)

View file

@ -0,0 +1,5 @@
package space.blokk.utils
import com.squareup.moshi.Moshi
fun Moshi.toJson(value: Map<*, *>) = adapter(Map::class.java).toJson(value)

View file

@ -1,4 +1,7 @@
package space.blokk.events
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.specification.describe
import strikt.api.expectThat
@ -14,7 +17,7 @@ private class SecondEvent: TestEvent()
object EventBusTest: Spek({
describe("EventBus") {
val eventBus by memoized { EventBus(TestEvent::class) }
val eventBus by memoized { EventBus(TestEvent::class, CoroutineScope(Dispatchers.Default)) }
it("calls the handler exactly 1 time when the event is emitted 1 time") {
var calledCount = 0
@ -22,7 +25,7 @@ object EventBusTest: Spek({
@EventHandler fun onFirstEvent(event: FirstEvent) { calledCount++ }
})
eventBus.emit(FirstEvent())
runBlocking { eventBus.emit(FirstEvent()) }
expectThat(calledCount).isEqualTo(1)
}
@ -32,9 +35,12 @@ object EventBusTest: Spek({
@EventHandler fun onFirstEvent(event: FirstEvent) { calledCount++ }
})
eventBus.emit(FirstEvent())
eventBus.emit(FirstEvent())
eventBus.emit(FirstEvent())
runBlocking {
eventBus.emit(FirstEvent())
eventBus.emit(FirstEvent())
eventBus.emit(FirstEvent())
}
expectThat(calledCount).isEqualTo(3)
}
@ -61,7 +67,7 @@ object EventBusTest: Spek({
@EventHandler fun onFirstEvent(event: FirstEvent) { onFirstEventCalled = true }
})
eventBus.emit(FirstEvent())
runBlocking { eventBus.emit(FirstEvent()) }
expectThat(onTestEventCalled).isTrue()
expectThat(onFirstEventCalled).isTrue()
@ -75,7 +81,7 @@ object EventBusTest: Spek({
eventBus.unregister(listener)
eventBus.emit(FirstEvent())
runBlocking { eventBus.emit(FirstEvent()) }
expectThat(called).isFalse()
}
@ -114,7 +120,7 @@ object EventBusTest: Spek({
@EventHandler(EventPriority.HIGH) fun onFirstEventHigh(event: FirstEvent) { order.add(EventPriority.HIGH) }
})
eventBus.emit(FirstEvent())
runBlocking { eventBus.emit(FirstEvent()) }
expectThat(order).isSorted(Comparator.comparing<EventPriority, Int> { it.ordinal })
}

View file

@ -2,8 +2,13 @@ package space.blokk
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import space.blokk.chat.ChatComponent
import space.blokk.chat.TextComponent
import space.blokk.events.EventBus
import space.blokk.events.EventHandler
import space.blokk.events.Listener
import space.blokk.net.BlokkSocketServer
import space.blokk.net.events.ServerListInfoRequestEvent
import space.blokk.server.Server
import space.blokk.server.events.ServerEvent
import space.blokk.utils.Logger
@ -14,7 +19,7 @@ class BlokkServer internal constructor(): Server {
override val scope = CoroutineScope(CoroutineName("BlokkServer"))
override val eventBus = EventBus(ServerEvent::class, scope)
val logger = Logger("BlokkServer")
var blokkSocketServer = BlokkSocketServer(this); private set
var socketServer = BlokkSocketServer(this); private set
// TODO: Read from config file
val host = "0.0.0.0"
@ -26,14 +31,14 @@ class BlokkServer internal constructor(): Server {
providerField.isAccessible = true
providerField.set(Blokk, object : BlokkProvider {
override val server = this@BlokkServer
override val sessions get() = this@BlokkServer.blokkSocketServer.allSessionsGroup
override val sessions get() = this@BlokkServer.socketServer.allSessionsGroup
})
providerField.isAccessible = false
}
fun start() {
logger info "Starting BlokkServer (${if (VERSION == "development") VERSION else "v$VERSION"})"
blokkSocketServer.bind()
socketServer.bind()
logger info "Listening on $host:$port"
}
@ -44,6 +49,13 @@ class BlokkServer internal constructor(): Server {
@JvmStatic
fun main(args: Array<String>) {
val server = BlokkServer()
Blokk.sessions.registerListener(object : Listener {
@EventHandler
fun onServerListInfoRequest(event: ServerListInfoRequestEvent) {
event.response = event.response.copy(description = TextComponent(event.session.address.hostAddress, bold = true, underlined = true, color = ChatComponent.Color.RED))
}
})
server.start()
}
}

View file

@ -4,9 +4,9 @@ import io.netty.channel.Channel
import kotlinx.coroutines.*
import space.blokk.BlokkServer
import space.blokk.events.*
import space.blokk.net.events.PacketReceivedEvent
import space.blokk.net.events.PacketSendEvent
import space.blokk.net.events.SessionEvent
import space.blokk.net.events.SessionPacketReceivedEvent
import space.blokk.net.events.SessionPacketSendEvent
import space.blokk.net.protocols.OutgoingPacket
import space.blokk.net.protocols.Protocol
import space.blokk.net.protocols.handshaking.HandshakingProtocol
@ -23,45 +23,48 @@ class BlokkSession(private val channel: Channel) : Session {
override var currentProtocol: Protocol = HandshakingProtocol
set(value) {
logger debug "Switching protocol: $currentProtocol -> $value"
if (value == field) return
logger trace "Switching protocol: $currentProtocol -> $value"
field = value
}
override val scope = CoroutineScope(Dispatchers.Unconfined + CoroutineName(identifier))
override val eventBus = EventBus(SessionEvent::class, scope)
var active: Boolean = true
private var active: Boolean = true
init {
eventBus.register(object : Listener {
@EventHandler(priority = EventPriority.INTERNAL)
suspend fun onSessionPacketReceived(event: SessionPacketReceivedEvent<*>) {
suspend fun onSessionPacketReceived(event: PacketReceivedEvent<*>) {
SessionPacketReceivedEventHandler.handle(event.session as BlokkSession, event.packet)
}
})
}
fun onConnect() = scope.launch {
if (BlokkServer.i.eventBus.emit(SessionInitializedEvent(this@BlokkSession)).isCancelled) channel.close()
else BlokkServer.i.blokkSocketServer.allSessionsGroup.add(this@BlokkSession)
logger trace "Connected"
if (BlokkServer.i.eventBus.emitAsync(SessionInitializedEvent(this@BlokkSession)).isCancelled) channel.close()
else BlokkServer.i.socketServer.allSessionsGroup.add(this@BlokkSession)
}
fun onDisconnect() {
if (!active) throw IllegalStateException("The session is not active anymore")
logger trace "Disconnected"
active = false
scope.cancel("Disconnected")
BlokkServer.i.blokkSocketServer.allSessionsGroup.remove(this)
BlokkServer.i.socketServer.allSessionsGroup.remove(this)
}
override suspend fun send(packet: OutgoingPacket) {
if (!active) throw IllegalStateException("The session is not active anymore")
logger debug { "Sending packet: $packet" }
val event = eventBus.emit(SessionPacketSendEvent(this@BlokkSession, packet))
event.ifNotCancelled {
logger trace { "Sending packet: $packet" }
eventBus.emit(PacketSendEvent(this@BlokkSession, packet)).ifNotCancelled {
try {
channel.writeAndFlush(PacketMessage(this@BlokkSession, event.packet)).awaitSuspending()
} catch (e: Throwable) {
logger error { "Packet send failed: $e" }
channel.writeAndFlush(PacketMessage(this@BlokkSession, it.packet)).awaitSuspending()
} catch (t: Throwable) {
logger.error("Packet send failed:", t)
}
}
}

View file

@ -34,6 +34,5 @@ class PacketCodec(private val session: BlokkSession): MessageToMessageCodec<Byte
// You can usually ignore connection errors as they are caused by modified clients such as hack clients
if (BlokkServer.i.silentConnectionErrors) session.logger.debug(message) else session.logger.error(message)
cause.printStackTrace()
}
}

View file

@ -2,17 +2,15 @@ package space.blokk.net
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.SimpleChannelInboundHandler
import kotlinx.coroutines.runBlocking
import space.blokk.net.events.SessionPacketReceivedEvent
import kotlinx.coroutines.launch
import space.blokk.net.events.PacketReceivedEvent
import space.blokk.net.protocols.IncomingPacket
class PacketMessageHandler(private val session: BlokkSession): SimpleChannelInboundHandler<PacketMessage<*>>() {
override fun channelRead0(ctx: ChannelHandlerContext, msg: PacketMessage<*>) {
if (msg.packet !is IncomingPacket) throw Error("Only serverbound packets are allowed. This should never happen.")
session.logger.debug { "Packet received: ${msg.packet}" }
runBlocking {
session.eventBus.emitAndAwait(SessionPacketReceivedEvent(session, msg.packet))
}
session.logger.trace { "Packet received: ${msg.packet}" }
session.scope.launch { session.eventBus.emit(PacketReceivedEvent(session, msg.packet)) }
// TODO: Disconnect when invalid data is received
}

View file

@ -2,24 +2,30 @@ package space.blokk.net.protocols.status
import space.blokk.chat.FormattingCode
import space.blokk.chat.TextComponent
import space.blokk.events.ifNotCancelled
import space.blokk.net.PacketReceivedEventHandler
import space.blokk.net.ProtocolPacketReceivedEventHandler
import space.blokk.net.events.ServerListInfoRequestEvent
import java.util.*
// NOTE: PacketReceivedEventHandler.of<T> MUST have T specified correctly, otherwise the code breaks at runtime
object StatusProtocolHandler: ProtocolPacketReceivedEventHandler(mapOf(
RequestPacket to PacketReceivedEventHandler.of<RequestPacket> { session, _ ->
session.send(ResponsePacket(
session.eventBus.emit(ServerListInfoRequestEvent(
session,
// TODO: Use the real server data
ResponsePacket(
versionName = "1.15.2",
protocolVersion = 578,
description = TextComponent of "nice",
description = TextComponent of "Hello World!",
players = ResponsePacket.Players(
10,
10,
listOf(ResponsePacket.Players.SampleEntry("${FormattingCode.AQUA}Gronkh", UUID.randomUUID().toString()))
10,
10,
listOf(ResponsePacket.Players.SampleEntry("${FormattingCode.AQUA}Gronkh", UUID.randomUUID().toString()))
)
))
)
)).ifNotCancelled { session.send(it.response) }
},
PingPacket to PacketReceivedEventHandler.of<PingPacket> { session, packet ->
session.send(PongPacket(packet.payload))

View file

@ -1,5 +1,6 @@
plugins {
kotlin("jvm") version "1.3.71"
kotlin("kapt") version "1.3.72"
}
group = "space.blokk"