diff --git a/game/application/pom.xml b/game/application/pom.xml index 9c075ec9c..0e29c3ad5 100644 --- a/game/application/pom.xml +++ b/game/application/pom.xml @@ -27,6 +27,10 @@ de.gleex.pltcmd.game serialization + + de.gleex.pltcmd.game + networking + org.hexworks.zircon zircon.jvm.swing diff --git a/game/application/src/main/kotlin/de/gleex/pltcmd/game/application/main.kt b/game/application/src/main/kotlin/de/gleex/pltcmd/game/application/main.kt index 134befd1b..e314ccfa8 100644 --- a/game/application/src/main/kotlin/de/gleex/pltcmd/game/application/main.kt +++ b/game/application/src/main/kotlin/de/gleex/pltcmd/game/application/main.kt @@ -2,6 +2,8 @@ package de.gleex.pltcmd.game.application import de.gleex.pltcmd.game.engine.Game import de.gleex.pltcmd.game.engine.entities.types.* +import de.gleex.pltcmd.game.networking.connect +import de.gleex.pltcmd.game.networking.createServer import de.gleex.pltcmd.game.options.GameOptions import de.gleex.pltcmd.game.options.UiOptions import de.gleex.pltcmd.game.serialization.StorageId @@ -21,6 +23,7 @@ import de.gleex.pltcmd.model.world.Sector import de.gleex.pltcmd.model.world.WorldMap import de.gleex.pltcmd.model.world.coordinate.Coordinate import de.gleex.pltcmd.model.world.sectorOrigin +import io.ktor.server.engine.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import org.hexworks.amethyst.api.Engine @@ -112,11 +115,25 @@ open class Main { val (elementsToCommand, hq) = prepareGame(game, gameWorld) + // networking + log.debug("starting network server") + val serverEngine = createServer(hq) + serverEngine.start(wait = false) + + log.debug("starting network client thread") + val clientThread = Thread { connect() } + clientThread.start() + screen.dock(GameView(gameWorld, tileGrid, game, hq, elementsToCommand)) Ticker.start() // cleanup - screen.onShutdown { Ticker.stop() } + screen.onShutdown { + log.debug("shutdown game") + Ticker.stop() + clientThread.stop() + serverEngine.stop(300, 500, TimeUnit.MILLISECONDS) + } } /** diff --git a/game/engine/src/main/kotlin/de/gleex/pltcmd/game/engine/entities/types/Communicating.kt b/game/engine/src/main/kotlin/de/gleex/pltcmd/game/engine/entities/types/Communicating.kt index 8c014bc0c..3acb14399 100644 --- a/game/engine/src/main/kotlin/de/gleex/pltcmd/game/engine/entities/types/Communicating.kt +++ b/game/engine/src/main/kotlin/de/gleex/pltcmd/game/engine/entities/types/Communicating.kt @@ -27,7 +27,8 @@ typealias CommunicatingEntity = GameEntity private val log = LoggerFactory.getLogger(Communicating::class) -internal val CommunicatingEntity.communicator: RadioCommunicator +// FIXME only public for testing! Should be internal and use extension functions to access transmissions. +val CommunicatingEntity.communicator: RadioCommunicator get() = getAttribute(RadioAttribute::class).communicator /** diff --git a/game/networking/pom.xml b/game/networking/pom.xml new file mode 100644 index 000000000..9ceddbeb4 --- /dev/null +++ b/game/networking/pom.xml @@ -0,0 +1,62 @@ + + + + game + de.gleex.pltcmd.game + 0.2.0-SNAPSHOT + + 4.0.0 + + networking + + + 1.6.3 + + + + + de.gleex.pltcmd.game + ui + + + de.gleex.pltcmd.game + engine + + + + org.jetbrains.kotlinx + kotlinx-serialization-protobuf + 1.1.0 + + + io.ktor + ktor-serialization + ${ktor.version} + + + + io.ktor + ktor-server-netty + ${ktor.version} + + + io.ktor + ktor-websockets + ${ktor.version} + + + + io.ktor + ktor-client-cio-jvm + ${ktor.version} + + + io.ktor + ktor-client-websockets + ${ktor.version} + + + + \ No newline at end of file diff --git a/game/networking/src/main/kotlin/de/gleex/pltcmd/game/networking/Client.kt b/game/networking/src/main/kotlin/de/gleex/pltcmd/game/networking/Client.kt new file mode 100644 index 000000000..4d8882c2a --- /dev/null +++ b/game/networking/src/main/kotlin/de/gleex/pltcmd/game/networking/Client.kt @@ -0,0 +1,44 @@ +package de.gleex.pltcmd.game.networking + +import de.gleex.pltcmd.model.radio.UiBroadcastEvent +import de.gleex.pltcmd.model.radio.UiBroadcasts +import de.gleex.pltcmd.util.events.uiEventBus +import io.ktor.client.* +import io.ktor.client.features.websocket.* +import io.ktor.http.* +import io.ktor.http.cio.websocket.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import org.hexworks.cobalt.events.api.simpleSubscribeTo +import org.hexworks.cobalt.logging.api.LoggerFactory + +private val log = LoggerFactory.getLogger(::connect::class) + +fun connect(host: String = "127.0.0.1", port: Int = defaultPort) { + val client = HttpClient { + install(WebSockets) + } + runBlocking { + log.info("Connecting to server $host:$port") + client.webSocket(method = HttpMethod.Get, host = host, port = port, path = pathBroadcastEvents) { + for (frame in incoming) { + val bytes = frame.readBytes() + val broadcastEvent = ProtoBuf.decodeFromByteArray(bytes) + log.trace{"received event $broadcastEvent"} + uiEventBus.publish(broadcastEvent, UiBroadcasts) + } + } + } + client.close() + log.info("Connection to server closed.") +} + +fun main() { + uiEventBus.simpleSubscribeTo(UiBroadcasts) { event: UiBroadcastEvent -> + log.info("received UI event: $event") + val message = event.message + println(message) + } + connect() +} diff --git a/game/networking/src/main/kotlin/de/gleex/pltcmd/game/networking/Server.kt b/game/networking/src/main/kotlin/de/gleex/pltcmd/game/networking/Server.kt new file mode 100644 index 000000000..61cf4ce4f --- /dev/null +++ b/game/networking/src/main/kotlin/de/gleex/pltcmd/game/networking/Server.kt @@ -0,0 +1,143 @@ +package de.gleex.pltcmd.game.networking + +import de.gleex.pltcmd.game.engine.entities.EntityFactory +import de.gleex.pltcmd.game.engine.entities.types.CommunicatingEntity +import de.gleex.pltcmd.game.engine.entities.types.communicator +import de.gleex.pltcmd.game.engine.entities.types.onReceivedTransmission +import de.gleex.pltcmd.game.engine.entities.types.onSendTransmission +import de.gleex.pltcmd.game.ticks.Ticker +import de.gleex.pltcmd.model.elements.CallSign +import de.gleex.pltcmd.model.faction.Faction +import de.gleex.pltcmd.model.radio.UiBroadcastEvent +import de.gleex.pltcmd.model.radio.communication.building.ConversationBuilder +import de.gleex.pltcmd.model.radio.communication.transmissions.Transmission +import de.gleex.pltcmd.model.radio.communication.transmissions.decoding.isOpening +import de.gleex.pltcmd.model.radio.communication.transmissions.decoding.sender +import de.gleex.pltcmd.model.radio.receivedTransmission +import de.gleex.pltcmd.model.world.Sector +import de.gleex.pltcmd.model.world.WorldMap +import de.gleex.pltcmd.model.world.WorldTile +import de.gleex.pltcmd.model.world.coordinate.Coordinate +import de.gleex.pltcmd.model.world.coordinate.CoordinateRectangle +import de.gleex.pltcmd.model.world.terrain.Terrain +import de.gleex.pltcmd.model.world.terrain.TerrainHeight +import de.gleex.pltcmd.model.world.terrain.TerrainType +import de.gleex.pltcmd.util.events.globalEventBus +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.cio.websocket.* +import io.ktor.routing.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.websocket.* +import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import org.hexworks.cobalt.logging.api.LoggerFactory +import java.util.concurrent.TimeUnit + +// there is also an `Application.log` provided by Ktor! +private val logger = LoggerFactory.getLogger(::createServer::class) + +internal const val defaultPort = 9170 +internal val pathBroadcastEvents = "/broadcasts" + +// TODO encapsulate return type. Providing the Ktor implementation class to the caller couples the code to this implementation. +fun createServer(hq: CommunicatingEntity): ApplicationEngine { + return embeddedServer(Netty, port = defaultPort) { + install(WebSockets) + routing { + broadcastsRoute(hq) + } + } +} + +private fun Routing.broadcastsRoute(hq: CommunicatingEntity) { + webSocket(pathBroadcastEvents) { + logger.info("sending broadcasts to $logId") + + val eventChannel = Channel(Channel.BUFFERED) + // listen to local events + val subscriptionReceived = hq.onReceivedTransmission(eventChannel::trySendLogging) + val subscriptionSend = hq.onSendTransmission(eventChannel::trySendLogging) + // clean up on disconnect + closeReason.invokeOnCompletion { + logger.debug("client closed connection $logId") + subscriptionReceived.dispose() + subscriptionSend.dispose() + eventChannel.close() + } + // send events to client + for (event in eventChannel) { + // second send over network + val bytes = ProtoBuf.encodeToByteArray(event) + send(bytes) + } + + logger.info("finished sending broadcasts to $logId") + } +} + +/** sends the given Event to the given channel and logs the result. */ +private fun Channel.trySendLogging(event: Transmission) { + // first convert + val uiEvent = event.uiEvent + // and queue event + val sendResult = trySend(uiEvent) + if (sendResult.isFailure) { + logger.error("failed to queue event $uiEvent for network transmission due to ${sendResult.exceptionOrNull()}") + } else if (sendResult.isSuccess) { + logger.trace("successfully queued event $uiEvent for network transmission") + } +} + +// TODO move somewhere else +val Transmission.uiEvent: UiBroadcastEvent + get() { + val message = "${Ticker.currentTimeString.value}: ${message}" + val senderName = sender.name + return UiBroadcastEvent(message, isOpening, senderName) + } + +internal val DefaultWebSocketServerSession.logId: String + get() { + val origin = call.request.origin + return origin.remoteHost + } + +fun main(args: Array) { + // setup communication model + logger.info("creating HQ...") + val sender = CallSign("sender") + val testTransmission = ConversationBuilder( + sender, + CallSign("receiver") + ).terminatingResponse("The test finished successfully :)") + val origin = Coordinate(0, 0) + val map = dummyMapAt(origin) + val hq: CommunicatingEntity = EntityFactory.newBaseAt(origin, map, Faction("example"), sender) + logger.info("done!") + + // start networking + val serverThread = createServer(hq) + serverThread.start(wait = false) + + // transfer data + repeat(3) { + logger.info("sending test") + globalEventBus.receivedTransmission(hq.communicator, testTransmission) + Thread.sleep(3000) + } + + // done + logger.info("Stopping server...") + serverThread.stop(200, 500, TimeUnit.MILLISECONDS) + logger.info("Stopped") +} + +private fun dummyMapAt(origin: Coordinate) = WorldMap.create(setOf(Sector( + origin, + CoordinateRectangle(origin, Sector.TILE_COUNT, Sector.TILE_COUNT) + .map { coordinate -> WorldTile(coordinate, Terrain.of(TerrainType.FOREST, TerrainHeight.FIVE)) } + .toSortedSet() +))) \ No newline at end of file diff --git a/game/networking/src/main/resources/logback.xml b/game/networking/src/main/resources/logback.xml new file mode 100644 index 000000000..3bcd3ce32 --- /dev/null +++ b/game/networking/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/game/options/pom.xml b/game/options/pom.xml index 808eb4a9f..fe7e0b1f5 100644 --- a/game/options/pom.xml +++ b/game/options/pom.xml @@ -17,6 +17,10 @@ de.gleex.pltcmd.model world + + org.hexworks.zircon + zircon.core-jvm + \ No newline at end of file diff --git a/game/pom.xml b/game/pom.xml index eee4f014a..65151bb65 100644 --- a/game/pom.xml +++ b/game/pom.xml @@ -22,13 +22,10 @@ engine ui-strings serialization + networking - - org.hexworks.zircon - zircon.core-jvm - de.gleex.pltcmd.util @@ -62,6 +59,11 @@ engine ${project.version} + + de.gleex.pltcmd.game + networking + ${project.version} + de.gleex.pltcmd.game serialization diff --git a/game/ui/src/main/kotlin/de/gleex/pltcmd/game/ui/views/GameView.kt b/game/ui/src/main/kotlin/de/gleex/pltcmd/game/ui/views/GameView.kt index 047040efd..913332bb7 100644 --- a/game/ui/src/main/kotlin/de/gleex/pltcmd/game/ui/views/GameView.kt +++ b/game/ui/src/main/kotlin/de/gleex/pltcmd/game/ui/views/GameView.kt @@ -16,9 +16,13 @@ import de.gleex.pltcmd.game.ui.components.InfoSidebar import de.gleex.pltcmd.game.ui.components.InputSidebar import de.gleex.pltcmd.game.ui.components.MapFragment import de.gleex.pltcmd.game.ui.entities.GameWorld +import de.gleex.pltcmd.model.radio.UiBroadcastEvent +import de.gleex.pltcmd.model.radio.UiBroadcasts import de.gleex.pltcmd.model.radio.communication.transmissions.Transmission import de.gleex.pltcmd.model.radio.communication.transmissions.decoding.isOpening import de.gleex.pltcmd.model.world.Sector +import de.gleex.pltcmd.util.events.uiEventBus +import org.hexworks.cobalt.events.api.simpleSubscribeTo import org.hexworks.cobalt.logging.api.LoggerFactory import org.hexworks.zircon.api.ComponentDecorations import org.hexworks.zircon.api.Components @@ -121,20 +125,13 @@ class GameView( } private fun LogArea.logRadioCalls() { - hq.onReceivedTransmission { - logTransmission(it) - }.disposeWhen(hiddenProperty) - hq.onSendTransmission { - logTransmission(it) - }.disposeWhen(hiddenProperty) - } - - private fun LogArea.logTransmission(transmission: Transmission) { - val message = "${Ticker.currentTimeString.value}: ${transmission.message}" - if (transmission.isOpening) { - addHeader(message, false) - } else { - addParagraph(message, false, 5) + uiEventBus.simpleSubscribeTo(UiBroadcasts) { event: UiBroadcastEvent -> + val message = event.message + if (event.isOpening) { + addHeader(message, false) + } else { + addParagraph(message, false, 5) + } } } } diff --git a/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/TransmissionReceivedEvent.kt b/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/TransmissionReceivedEvent.kt index a4b0afec8..1aff5b3ef 100644 --- a/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/TransmissionReceivedEvent.kt +++ b/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/TransmissionReceivedEvent.kt @@ -46,7 +46,8 @@ fun EventBus.subscribeToReceivedTransmissions( * @param receiver the [RadioCommunicator] that received the transmission * @param transmission the received [Transmission] */ -internal fun EventBus.receivedTransmission(receiver: RadioCommunicator, transmission: Transmission) = +// TODO public for testing only! Should be internal and models must be used to access Transmissions. +fun EventBus.receivedTransmission(receiver: RadioCommunicator, transmission: Transmission) = publish(TransmissionReceivedEvent(receiver, transmission), Transmissions) /** Returns a key that uniquely identifies this receiving object */ diff --git a/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/UiBroadcastEvent.kt b/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/UiBroadcastEvent.kt new file mode 100644 index 000000000..ac931d319 --- /dev/null +++ b/model/communication/src/main/kotlin/de/gleex/pltcmd/model/radio/UiBroadcastEvent.kt @@ -0,0 +1,13 @@ +package de.gleex.pltcmd.model.radio + +import kotlinx.serialization.Serializable +import org.hexworks.cobalt.events.api.Event +import org.hexworks.cobalt.events.api.EventScope + +@Serializable +data class UiBroadcastEvent(val message: String, val isOpening: Boolean, override val emitter: String) : Event + +/** + * This scope is used for radio broadcasts displayed in the UI. + */ +object UiBroadcasts : EventScope diff --git a/pom.xml b/pom.xml index a611fcfac..3b9f82b5c 100644 --- a/pom.xml +++ b/pom.xml @@ -30,10 +30,10 @@ 11 11 - 1.5.10 - - 1.5.0 - 1.2.1 + 1.5.20 + + 1.5.0-native-mt + 1.2.2 2020.2.0-RELEASE 2021.0.1-RELEASE @@ -50,6 +50,11 @@ kotlin-stdlib-jdk8 ${kotlin.version} + + org.jetbrains.kotlinx + kotlinx-coroutines-core + ${kotlinx.coroutines.version} + org.jetbrains.kotlinx kotlinx-serialization-core @@ -112,8 +117,10 @@ org.jetbrains.kotlinx - kotlinx-coroutines-core-jvm + kotlinx-coroutines-bom ${kotlinx.coroutines.version} + pom + import org.hexworks.zircon diff --git a/util/events/src/main/kotlin/de/gleex/pltcmd/util/events/EventBus.kt b/util/events/src/main/kotlin/de/gleex/pltcmd/util/events/EventBus.kt index 9b6753050..1c50b9219 100644 --- a/util/events/src/main/kotlin/de/gleex/pltcmd/util/events/EventBus.kt +++ b/util/events/src/main/kotlin/de/gleex/pltcmd/util/events/EventBus.kt @@ -6,3 +6,8 @@ import org.hexworks.cobalt.events.api.EventBus * Instance for all events. */ val globalEventBus = EventBus.create() + +/** + * Instance for client events. + */ +val uiEventBus = EventBus.create()