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()