diff --git a/.editorconfig b/.editorconfig
index 4f7927b..2017784 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -2,5 +2,5 @@
ktlint_code_style = ktlint_official
ktlint_standard = enabled
ktlint_experimental = enabled
-ktlint_standard_string-template-indent = disabled
-ktlint_standard_multiline-expression-wrapping = disabled
+ktlint_standard_string-template-indent = enabled
+ktlint_standard_multiline-expression-wrapping = enabled
diff --git a/.run/ApplicationKt.run.xml b/.run/ApplicationKt.run.xml
deleted file mode 100644
index f7e71b5..0000000
--- a/.run/ApplicationKt.run.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 4cbb236..27b9ece 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ plugins {
kotlin("jvm") version "2.1.10"
id("io.ktor.plugin") version "2.3.8"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
- id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
+ id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
}
group = "io.github.flecomte"
@@ -26,6 +26,11 @@ application {
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
+configure {
+ version.set("1.5.0")
+ enableExperimentalRules.set(true)
+}
+
repositories {
mavenCentral()
}
diff --git a/src/main/kotlin/eventDemo/Configure.kt b/src/main/kotlin/eventDemo/Configure.kt
index 073cf83..9c88816 100644
--- a/src/main/kotlin/eventDemo/Configure.kt
+++ b/src/main/kotlin/eventDemo/Configure.kt
@@ -1,19 +1,29 @@
package eventDemo
-import eventDemo.plugins.configureHTTP
+import eventDemo.app.actions.GameEventReactionSubscriber
+import eventDemo.plugins.configureHttp
+import eventDemo.plugins.configureHttpRouting
import eventDemo.plugins.configureKoin
-import eventDemo.plugins.configureRouting
import eventDemo.plugins.configureSecurity
import eventDemo.plugins.configureSerialization
import eventDemo.plugins.configureSockets
+import eventDemo.plugins.configureWebSocketsGameRoute
import io.ktor.server.application.Application
+import org.koin.ktor.ext.get
fun Application.configure() {
- configureSecurity()
- configureSerialization()
- configureSockets()
- configureHTTP()
- configureRouting()
configureKoin()
- configureCommandHandler()
+
+ configureSecurity()
+
+ configureSerialization()
+
+ configureSockets()
+ configureWebSocketsGameRoute(get(), get())
+
+ configureHttp()
+ configureHttpRouting()
+
+ GameEventReactionSubscriber(get(), get())
+ .init()
}
diff --git a/src/main/kotlin/eventDemo/Init.kt b/src/main/kotlin/eventDemo/Init.kt
deleted file mode 100644
index 1d6ad2f..0000000
--- a/src/main/kotlin/eventDemo/Init.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package eventDemo
-
-import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
-import io.ktor.server.application.Application
-import org.koin.java.KoinJavaComponent.getKoin
-
-/**
- * Configure the command handler for the PlayCard.
- */
-fun Application.configureCommandHandler() {
- getKoin()
- .get()
- .init()
-}
diff --git a/src/main/kotlin/eventDemo/app/actions/GameCommandHandler.kt b/src/main/kotlin/eventDemo/app/actions/GameCommandHandler.kt
new file mode 100644
index 0000000..4b72936
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/actions/GameCommandHandler.kt
@@ -0,0 +1,73 @@
+package eventDemo.app.actions
+
+import eventDemo.app.actions.playNewCard.PlayCardCommand
+import eventDemo.shared.command.GameCommandStream
+import eventDemo.shared.entity.Player
+import eventDemo.shared.event.CardIsPlayedEvent
+import eventDemo.shared.event.GameEvent
+import eventDemo.shared.event.GameEventStream
+import eventDemo.shared.event.GameState
+import eventDemo.shared.event.buildStateFromEventStream
+import io.ktor.websocket.Frame
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+/**
+ * Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
+ *
+ * This action can be executing an action and produce a new [GameEvent] after verification.
+ */
+class GameCommandHandler(
+ private val eventStream: GameEventStream,
+ incoming: ReceiveChannel,
+ outgoing: SendChannel,
+) {
+ private val commandStream = GameCommandStream(incoming, outgoing)
+ private val playerNotifier = outgoing
+
+ /**
+ * Init the handler
+ */
+ fun init(player: Player) {
+ CoroutineScope(Dispatchers.IO).launch {
+ commandStream.process {
+ if (it.payload.player.id != player.id) {
+ nack()
+ }
+ when (it) {
+ is PlayCardCommand -> {
+ // Check the command can be executed
+ val canBeExecuted =
+ it.payload.gameId
+ .buildStateFromEventStream(eventStream)
+ .commandCardCanBeExecuted(it)
+
+ if (canBeExecuted) {
+ eventStream.publish(
+ CardIsPlayedEvent(
+ it.payload.gameId,
+ it.payload.card,
+ it.payload.player,
+ ),
+ )
+ } else {
+ runBlocking {
+ playerNotifier.send(Frame.Text("Command cannot be executed"))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun GameState.commandCardCanBeExecuted(command: PlayCardCommand): Boolean =
+ canBePlayThisCard(
+ command.payload.player,
+ command.payload.card,
+ )
diff --git a/src/main/kotlin/eventDemo/app/actions/GameEventPlayerNotificationSubscriber.kt b/src/main/kotlin/eventDemo/app/actions/GameEventPlayerNotificationSubscriber.kt
new file mode 100644
index 0000000..cb96520
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/actions/GameEventPlayerNotificationSubscriber.kt
@@ -0,0 +1,22 @@
+package eventDemo.app.actions
+
+import eventDemo.libs.event.EventBus
+import eventDemo.shared.GameId
+import eventDemo.shared.event.GameEvent
+import eventDemo.shared.toFrame
+import io.ktor.websocket.Frame
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.runBlocking
+
+class GameEventPlayerNotificationSubscriber(
+ private val eventBus: EventBus,
+ private val outgoing: SendChannel,
+) {
+ fun init() {
+ eventBus.subscribe { event: GameEvent ->
+ runBlocking {
+ outgoing.send(event.toFrame())
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/app/actions/GameEventReactionSubscriber.kt b/src/main/kotlin/eventDemo/app/actions/GameEventReactionSubscriber.kt
new file mode 100644
index 0000000..07d29d8
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/actions/GameEventReactionSubscriber.kt
@@ -0,0 +1,27 @@
+package eventDemo.app.actions
+
+import eventDemo.libs.event.EventBus
+import eventDemo.libs.event.EventStream
+import eventDemo.shared.GameId
+import eventDemo.shared.event.GameEvent
+import eventDemo.shared.event.GameStartedEvent
+import eventDemo.shared.event.buildStateFromEventStream
+
+class GameEventReactionSubscriber(
+ private val eventBus: EventBus,
+ private val eventStream: EventStream,
+) {
+ fun init() {
+ eventBus.subscribe { event: GameEvent ->
+ val state = event.id.buildStateFromEventStream(eventStream)
+ if (state.isReady) {
+ eventStream.publish(
+ GameStartedEvent.new(
+ state.gameId,
+ state.players,
+ ),
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpRoute.kt b/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpRoute.kt
index dadf4ad..2f2f15c 100644
--- a/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpRoute.kt
+++ b/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpRoute.kt
@@ -1,14 +1,15 @@
package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.send
-import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.GameId
-import eventDemo.shared.command.GameCommandStream
+import eventDemo.shared.command.GameCommandStreamInMemory
import eventDemo.shared.entity.Card
-import eventDemo.shared.entity.Game
+import eventDemo.shared.entity.Player
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
+import io.ktor.server.auth.authenticate
+import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.resources.post
import io.ktor.server.response.respondNullable
@@ -16,12 +17,11 @@ import io.ktor.server.routing.Routing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
-import org.koin.ktor.ext.inject
@Serializable
@Resource("/game/{id}")
class GameRoute(
- @Serializable(with = GameIdSerializer::class)
+// @Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@@ -35,19 +35,27 @@ class GameRoute(
* API route to send a request to play card.
*/
fun Routing.playNewCard() {
- val commandStream by inject()
+ val commandStream = GameCommandStreamInMemory()
+ authenticate {
+ /*
+ * A player request to play a new card.
+ *
+ * It always returns [HttpStatusCode.OK], but it is not mean that card is already played!
+ */
+ post {
+ val card = call.receive()
+ val name = call.principal()!!
+ launch(Dispatchers.Default) {
+ commandStream.send(
+ PlayCardCommand(
+ it.game.id,
+ name,
+ card,
+ ),
+ )
+ }
- /*
- * A player request to play a new card.
- *
- * It always returns [HttpStatusCode.OK], but it is not mean that card is already played!
- */
- post {
- val card = call.receive()
- launch(Dispatchers.Default) {
- commandStream.send(PlayCardCommand(Game(it.game.id), card))
+ call.respondNullable(HttpStatusCode.OK, null)
}
-
- call.respondNullable(HttpStatusCode.OK, null)
}
}
diff --git a/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt
index 368a5e1..f02e337 100644
--- a/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt
+++ b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt
@@ -2,8 +2,9 @@ package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.Command
import eventDemo.libs.command.CommandId
+import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
-import eventDemo.shared.entity.Game
+import eventDemo.shared.entity.Player
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -13,19 +14,32 @@ import kotlinx.serialization.Serializable
@Serializable
@SerialName("PlayCard")
data class PlayCardCommand(
- val payload: Payload,
-) : Command {
+ override val payload: Payload,
+) : GameCommand {
constructor(
- game: Game,
+ gameId: GameId,
+ player: Player,
card: Card,
- ) : this(Payload(game, card))
+ ) : this(Payload(gameId, player, card))
override val name: String = "PlayCard"
override val id: CommandId = CommandId()
@Serializable
data class Payload(
- val game: Game,
+ override val gameId: GameId,
+ override val player: Player,
val card: Card,
- )
+ ) : GameCommand.Payload
+}
+
+@Serializable
+sealed interface GameCommand : Command {
+ val payload: Payload
+
+ @Serializable
+ sealed interface Payload {
+ val gameId: GameId
+ val player: Player
+ }
}
diff --git a/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt
deleted file mode 100644
index 5864ab9..0000000
--- a/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package eventDemo.app.actions.playNewCard
-
-import eventDemo.shared.command.GameCommandStream
-import eventDemo.shared.event.CardIsPlayedEvent
-import eventDemo.shared.event.GameEventStream
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-
-/**
- * Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
- *
- * This action produces a new [CardIsPlayedEvent]
- */
-class PlayCardCommandHandler(
- private val commandStream: GameCommandStream,
- private val eventStream: GameEventStream,
-) {
- /**
- * Init the handler
- */
- fun init() {
- CoroutineScope(Dispatchers.IO).launch {
- commandStream.process {
- // TODO check the command can be executed
- eventStream.publish(CardIsPlayedEvent(it.payload.game.id, it.payload.card))
- }
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt
new file mode 100644
index 0000000..f89661f
--- /dev/null
+++ b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt
@@ -0,0 +1,97 @@
+package eventDemo.libs.command
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+import io.ktor.websocket.Frame
+import io.ktor.websocket.readText
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.runBlocking
+import kotlin.reflect.KClass
+
+/**
+ * Manage [Command]'s with kotlin Channel
+ */
+class CommandStreamChannel(
+ private val incoming: ReceiveChannel,
+ private val outgoing: SendChannel,
+ private val serializer: (C) -> String,
+ private val deserializer: (String) -> C,
+) : CommandStream {
+ private val logger = KotlinLogging.logger {}
+ private val failedCommand = mutableListOf()
+
+ /**
+ * Send a new [Command] to the queue.
+ */
+ override suspend fun send(
+ type: KClass,
+ command: C,
+ ) {
+ logger.atInfo {
+ message = "Command published: $command"
+ payload = mapOf("command" to command)
+ }
+
+ outgoing.send(Frame.Text(serializer(command)))
+ }
+
+ override suspend fun process(action: CommandStream.ComputeStatus.(C) -> Unit) {
+// incoming.consumeEach { commandAsFrame ->
+// if (commandAsFrame is Frame.Text) {
+// compute(deserializer(commandAsFrame.readText()), action)
+// }
+// }
+ for (command in incoming) {
+ if (command is Frame.Text) {
+ compute(deserializer(command.readText()), action)
+ }
+ }
+ }
+
+ private fun compute(
+ command: C,
+ action: CommandStream.ComputeStatus.(C) -> Unit,
+ ) {
+ val status =
+ object : CommandStream.ComputeStatus {
+ var isSet: Boolean = false
+
+ override fun ack() {
+ if (!isSet) markAsSuccess(command) else error("Already NACK")
+ isSet = true
+ }
+
+ override fun nack() {
+ if (!isSet) markAsFailed(command) else error("Already ACK")
+ isSet = true
+ }
+ }
+
+ if (runCatching { status.action(command) }.isFailure) {
+ markAsFailed(command)
+ } else if (!status.isSet) {
+ status.ack()
+ }
+ }
+
+ private fun markAsSuccess(command: C) {
+ logger.atInfo {
+ message = "Compute command SUCCESS and it removed of the stack : $command"
+ payload = mapOf("command" to command)
+ }
+ runBlocking {
+ outgoing.send(Frame.Text("Command executed successfully"))
+ }
+ }
+
+ private fun markAsFailed(command: C) {
+ failedCommand.add(command)
+ logger.atWarn {
+ message = "Compute command FAILED and it put it ot the top of the stack : $command"
+ payload = mapOf("command" to command)
+ }
+ runBlocking {
+ outgoing.send(Frame.Text("Command execution failed"))
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt
index f2f5300..474d9ae 100644
--- a/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt
+++ b/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt
@@ -44,19 +44,20 @@ abstract class CommandStreamInMemory : CommandStream {
command: C,
action: CommandBlock,
) {
- val status = object : CommandStream.ComputeStatus {
- var isSet: Boolean = false
+ val status =
+ object : CommandStream.ComputeStatus {
+ var isSet: Boolean = false
- override fun ack() {
- if (!isSet) markAsSuccess(command) else error("Already NACK")
- isSet = true
- }
+ override fun ack() {
+ if (!isSet) markAsSuccess(command) else error("Already NACK")
+ isSet = true
+ }
- override fun nack() {
- if (!isSet) markAsFailed(command) else error("Already ACK")
- isSet = true
+ override fun nack() {
+ if (!isSet) markAsFailed(command) else error("Already ACK")
+ isSet = true
+ }
}
- }
if (runCatching { status.action(command) }.isFailure) {
markAsFailed(command)
diff --git a/src/main/kotlin/eventDemo/libs/event/EventBus.kt b/src/main/kotlin/eventDemo/libs/event/EventBus.kt
new file mode 100644
index 0000000..311a810
--- /dev/null
+++ b/src/main/kotlin/eventDemo/libs/event/EventBus.kt
@@ -0,0 +1,7 @@
+package eventDemo.libs.event
+
+interface EventBus, ID : AggregateId> {
+ fun publish(event: E)
+
+ fun subscribe(block: (E) -> Unit)
+}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt b/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt
new file mode 100644
index 0000000..3723f6f
--- /dev/null
+++ b/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt
@@ -0,0 +1,15 @@
+package eventDemo.libs.event
+
+class EventBusInMemory, ID : AggregateId> : EventBus {
+ private val subscribers: MutableList<(E) -> Unit> = mutableListOf()
+
+ override fun publish(event: E) {
+ subscribers.forEach {
+ it(event)
+ }
+ }
+
+ override fun subscribe(block: (E) -> Unit) {
+ subscribers.add(block)
+ }
+}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStream.kt b/src/main/kotlin/eventDemo/libs/event/EventStream.kt
index b6e32bd..e38b6b1 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStream.kt
+++ b/src/main/kotlin/eventDemo/libs/event/EventStream.kt
@@ -1,6 +1,5 @@
package eventDemo.libs.event
-import kotlinx.coroutines.flow.Flow
import kotlin.reflect.KClass
/**
@@ -22,6 +21,6 @@ interface EventStream, ID : AggregateId> {
eventType: KClass,
): E?
- /** Reads all events associated with a given aggregate ID as a Flow (asynchronous stream) */
- fun readAll(aggregateId: ID): Flow
+ /** Reads all events associated with a given aggregate ID */
+ fun readAll(aggregateId: ID): List
}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
index c5ace22..4d76204 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
+++ b/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
@@ -1,8 +1,6 @@
package eventDemo.libs.event
import io.github.oshai.kotlinlogging.KotlinLogging
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
import kotlin.reflect.KClass
/**
@@ -10,14 +8,12 @@ import kotlin.reflect.KClass
*
* All methods are implemented.
*/
-abstract class EventStreamInMemory, ID : AggregateId>(
- private val eventType: Class,
-) : EventStream {
+class EventStreamInMemory, ID : AggregateId> : EventStream {
private val logger = KotlinLogging.logger {}
- private val eventBus: MutableList = mutableListOf()
+ private val events: MutableList = mutableListOf()
override fun publish(event: E) {
- eventBus.add(event)
+ events.add(event)
logger.atInfo {
message = "Event published: $event"
payload = mapOf("event" to event)
@@ -28,20 +24,17 @@ abstract class EventStreamInMemory, ID : AggregateId>(
events.forEach { publish(it) }
}
- override fun readLast(aggregateId: ID): E? = eventBus.lastOrNull()
+ override fun readLast(aggregateId: ID): E? = events.lastOrNull()
override fun readLastOf(
aggregateId: ID,
eventType: KClass,
): R? =
- eventBus
+ events
.filterIsInstance(eventType.java)
.lastOrNull { it.id == aggregateId }
- override fun readAll(aggregateId: ID): Flow =
- flow {
- eventBus.forEach { emit(it) }
- }
+ override fun readAll(aggregateId: ID): List = events
}
inline fun , ID : AggregateId> EventStreamInMemory.readLastOf(aggregateId: ID): R? =
diff --git a/src/main/kotlin/eventDemo/plugins/HTTP.kt b/src/main/kotlin/eventDemo/plugins/HTTP.kt
index f5f19f1..7d17ec3 100644
--- a/src/main/kotlin/eventDemo/plugins/HTTP.kt
+++ b/src/main/kotlin/eventDemo/plugins/HTTP.kt
@@ -7,7 +7,7 @@ import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS
-fun Application.configureHTTP() {
+fun Application.configureHttp() {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
diff --git a/src/main/kotlin/eventDemo/plugins/Koin.kt b/src/main/kotlin/eventDemo/plugins/Koin.kt
index 7b9cb80..28c114e 100644
--- a/src/main/kotlin/eventDemo/plugins/Koin.kt
+++ b/src/main/kotlin/eventDemo/plugins/Koin.kt
@@ -1,11 +1,11 @@
package eventDemo.plugins
-import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
-import eventDemo.shared.command.GameCommandStream
+import eventDemo.libs.event.EventBusInMemory
+import eventDemo.libs.event.EventStreamInMemory
+import eventDemo.shared.event.GameEventBus
import eventDemo.shared.event.GameEventStream
import io.ktor.server.application.Application
import io.ktor.server.application.install
-import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
@@ -13,13 +13,16 @@ import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
install(Koin) {
slf4jLogger()
- modules(appModule)
+ modules(appKoinModule)
}
}
-val appModule =
+val appKoinModule =
module {
- singleOf(::GameEventStream)
- singleOf(::GameCommandStream)
- singleOf(::PlayCardCommandHandler)
+ single {
+ GameEventStream(get(), EventStreamInMemory())
+ }
+ single {
+ GameEventBus(EventBusInMemory())
+ }
}
diff --git a/src/main/kotlin/eventDemo/plugins/Routing.kt b/src/main/kotlin/eventDemo/plugins/Routing.kt
index f650435..449d95c 100644
--- a/src/main/kotlin/eventDemo/plugins/Routing.kt
+++ b/src/main/kotlin/eventDemo/plugins/Routing.kt
@@ -11,7 +11,7 @@ import io.ktor.server.resources.Resources
import io.ktor.server.response.respondText
import io.ktor.server.routing.routing
-fun Application.configureRouting() {
+fun Application.configureHttpRouting() {
install(AutoHeadResponse)
install(Resources)
install(StatusPages) {
diff --git a/src/main/kotlin/eventDemo/plugins/Security.kt b/src/main/kotlin/eventDemo/plugins/Security.kt
index 75c754e..4a3e976 100644
--- a/src/main/kotlin/eventDemo/plugins/Security.kt
+++ b/src/main/kotlin/eventDemo/plugins/Security.kt
@@ -2,25 +2,22 @@ package eventDemo.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
+import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
-import io.ktor.server.auth.UserIdPrincipal
-import io.ktor.server.auth.authenticate
import io.ktor.server.auth.authentication
-import io.ktor.server.auth.basic
-import io.ktor.server.auth.form
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
-import io.ktor.server.auth.principal
-import io.ktor.server.response.respondText
+import io.ktor.server.response.respond
import io.ktor.server.routing.get
+import io.ktor.server.routing.post
import io.ktor.server.routing.routing
+import java.util.Date
fun Application.configureSecurity() {
- // Please read the jwt property from the config file if you are using EngineMain
- val jwtAudience = "jwt-audience"
- val jwtDomain = "https://jwt-provider-domain/"
- val jwtRealm = "ktor sample app"
+ // TODO: read the jwt property from the config file
+ val jwtRealm = "Play card game"
+ val jwtIssuer = "PlayCardGame"
val jwtSecret = "secret"
authentication {
jwt {
@@ -28,47 +25,35 @@ fun Application.configureSecurity() {
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
- .withAudience(jwtAudience)
- .withIssuer(jwtDomain)
+ .withIssuer(jwtIssuer)
.build(),
)
validate { credential ->
- if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
- }
- }
- }
- authentication {
- basic(name = "myauth1") {
- realm = "Ktor Server"
- validate { credentials ->
- if (credentials.name == credentials.password) {
- UserIdPrincipal(credentials.name)
+ if (credential.payload.getClaim("username").asString() != "") {
+ JWTPrincipal(credential.payload)
} else {
null
}
}
- }
-
- form(name = "myauth2") {
- userParamName = "user"
- passwordParamName = "password"
- challenge {
- //
+ challenge { defaultScheme, realm ->
+ call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
}
+
routing {
- authenticate("myauth1") {
- get("/protected/route/basic") {
- val principal = call.principal()!!
- call.respondText("Hello ${principal.name}")
- }
- }
- authenticate("myauth2") {
- get("/protected/route/form") {
- val principal = call.principal()!!
- call.respondText("Hello ${principal.name}")
- }
+ post("login/{username}") {
+ val username = call.parameters["username"]
+
+ val token =
+ JWT
+ .create()
+ .withIssuer(jwtIssuer)
+ .withClaim("username", username)
+ .withExpiresAt(Date(System.currentTimeMillis() + 60000))
+ .sign(Algorithm.HMAC256(jwtSecret))
+
+ call.respond(hashMapOf("token" to token))
}
}
}
diff --git a/src/main/kotlin/eventDemo/plugins/Serialization.kt b/src/main/kotlin/eventDemo/plugins/Serialization.kt
index e4c4fe3..6d51e76 100644
--- a/src/main/kotlin/eventDemo/plugins/Serialization.kt
+++ b/src/main/kotlin/eventDemo/plugins/Serialization.kt
@@ -2,6 +2,7 @@ package eventDemo.plugins
import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId
+import eventDemo.shared.entity.Player.PlayerId
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
@@ -42,6 +43,19 @@ object CommandIdSerializer : KSerializer {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING)
}
+object PlayerIdSerializer : KSerializer {
+ override fun deserialize(decoder: Decoder): PlayerId = PlayerId(UUID.fromString(decoder.decodeString()))
+
+ override fun serialize(
+ encoder: Encoder,
+ value: PlayerId,
+ ) {
+ encoder.encodeString(value.id.toString())
+ }
+
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING)
+}
+
object GameIdSerializer : KSerializer {
override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString()))
diff --git a/src/main/kotlin/eventDemo/plugins/Sockets.kt b/src/main/kotlin/eventDemo/plugins/Sockets.kt
index 6a87f10..d965d6c 100644
--- a/src/main/kotlin/eventDemo/plugins/Sockets.kt
+++ b/src/main/kotlin/eventDemo/plugins/Sockets.kt
@@ -1,16 +1,21 @@
package eventDemo.plugins
+import eventDemo.app.actions.GameCommandHandler
+import eventDemo.app.actions.GameEventPlayerNotificationSubscriber
+import eventDemo.shared.entity.Player
+import eventDemo.shared.event.GameEventBus
+import eventDemo.shared.event.GameEventStream
import io.ktor.server.application.Application
+import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
+import io.ktor.server.auth.authenticate
+import io.ktor.server.auth.jwt.JWTPrincipal
+import io.ktor.server.auth.principal
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
-import io.ktor.websocket.CloseReason
-import io.ktor.websocket.Frame
-import io.ktor.websocket.close
-import io.ktor.websocket.readText
import java.time.Duration
fun Application.configureSockets() {
@@ -20,18 +25,26 @@ fun Application.configureSockets() {
maxFrameSize = Long.MAX_VALUE
masking = false
}
+}
+
+fun Application.configureWebSocketsGameRoute(
+ eventStream: GameEventStream,
+ eventBus: GameEventBus,
+) {
routing {
- webSocket("/ws") {
- // websocketSession
- for (frame in incoming) {
- if (frame is Frame.Text) {
- val text = frame.readText()
- outgoing.send(Frame.Text("YOU SAID: $text"))
- if (text.equals("bye", ignoreCase = true)) {
- close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
- }
- }
+ authenticate {
+ webSocket("/game") {
+ GameCommandHandler(eventStream, incoming, outgoing).init(call.getPlayer())
+ GameEventPlayerNotificationSubscriber(eventBus, outgoing).init()
}
}
}
}
+
+fun ApplicationCall.getPlayer() =
+ principal()!!.run {
+ Player(
+ id = payload.getClaim("playerid").asString(),
+ name = payload.getClaim("username").asString(),
+ )
+ }
diff --git a/src/main/kotlin/eventDemo/shared/FrameConverter.kt b/src/main/kotlin/eventDemo/shared/FrameConverter.kt
new file mode 100644
index 0000000..18a7f92
--- /dev/null
+++ b/src/main/kotlin/eventDemo/shared/FrameConverter.kt
@@ -0,0 +1,15 @@
+package eventDemo.shared
+
+import eventDemo.app.actions.playNewCard.GameCommand
+import eventDemo.shared.event.GameEvent
+import io.ktor.websocket.Frame
+import io.ktor.websocket.readText
+import kotlinx.serialization.json.Json
+
+fun Frame.Text.toEvent(): GameEvent = Json.decodeFromString(GameEvent.serializer(), readText())
+
+fun GameEvent.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameEvent.serializer(), this))
+
+fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText())
+
+fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this))
diff --git a/src/main/kotlin/eventDemo/shared/GameId.kt b/src/main/kotlin/eventDemo/shared/GameId.kt
index 5f28a28..f4c930d 100644
--- a/src/main/kotlin/eventDemo/shared/GameId.kt
+++ b/src/main/kotlin/eventDemo/shared/GameId.kt
@@ -2,19 +2,14 @@ package eventDemo.shared
import eventDemo.libs.event.AggregateId
import eventDemo.plugins.GameIdSerializer
-import eventDemo.shared.entity.Game
import kotlinx.serialization.Serializable
import java.util.UUID
/**
- * An [AggregateId] for the [Game].
+ * An [AggregateId] for a game.
*/
@JvmInline
@Serializable(with = GameIdSerializer::class)
value class GameId(
override val id: UUID = UUID.randomUUID(),
-) : AggregateId {
- constructor(id: String) : this(UUID.fromString(id))
-
- override fun toString(): String = id.toString()
-}
+) : AggregateId
diff --git a/src/main/kotlin/eventDemo/shared/GameState.kt b/src/main/kotlin/eventDemo/shared/GameState.kt
new file mode 100644
index 0000000..d789a71
--- /dev/null
+++ b/src/main/kotlin/eventDemo/shared/GameState.kt
@@ -0,0 +1,143 @@
+package eventDemo.shared.event
+
+import eventDemo.shared.GameId
+import eventDemo.shared.entity.Card
+import eventDemo.shared.entity.Deck
+import eventDemo.shared.entity.Player
+
+data class GameState(
+ val gameId: GameId,
+ val players: Set = emptySet(),
+ val lastPlayer: Player? = null,
+ val lastCard: LastCard? = null,
+ val lastColor: Card.Color? = null,
+ val direction: Direction = Direction.CLOCKWISE,
+ val readyPlayers: List = emptyList(),
+ val deck: Deck = Deck(players.toList()),
+ val isStarted: Boolean = false,
+) {
+ data class LastCard(
+ val card: Card,
+ val player: Player,
+ )
+
+ enum class Direction {
+ CLOCKWISE,
+ COUNTER_CLOCKWISE,
+ ;
+
+ fun revert(): Direction =
+ if (this === CLOCKWISE) {
+ COUNTER_CLOCKWISE
+ } else {
+ CLOCKWISE
+ }
+ }
+
+ val isReady: Boolean get() {
+ return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
+ }
+
+ fun canBePlayThisCard(
+ player: Player,
+ card: Card,
+ ): Boolean {
+ if (!isReady) return false
+ val cardOnGame = lastCard?.card ?: return false
+
+ return when (cardOnGame) {
+ is Card.NumericCard -> {
+ when (card) {
+ is Card.AllColorCard -> true
+ is Card.NumericCard -> card.number == cardOnGame.number || card.color == cardOnGame.color
+ is Card.ColorCard -> card.color == cardOnGame.color
+ }
+ }
+
+ is Card.ReverseCard -> {
+ when (card) {
+ is Card.ReverseCard -> true
+ is Card.AllColorCard -> true
+ is Card.ColorCard -> card.color == cardOnGame.color
+ }
+ }
+
+ is Card.PassCard -> {
+ if (player.cardOnBoardIsForYou) {
+ false
+ } else {
+ when (card) {
+ is Card.AllColorCard -> true
+ is Card.ColorCard -> card.color == cardOnGame.color
+ }
+ }
+ }
+
+ is Card.ChangeColorCard -> {
+ when (card) {
+ is Card.AllColorCard -> true
+ is Card.ColorCard -> card.color == lastColor
+ }
+ }
+
+ is Card.Plus2Card -> {
+ if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
+ true
+ } else {
+ when (card) {
+ is Card.AllColorCard -> true
+ is Card.Plus2Card -> true
+ is Card.ColorCard -> card.color == cardOnGame.color
+ }
+ }
+ }
+
+ is Card.Plus4Card -> {
+ if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
+ true
+ } else {
+ when (card) {
+ is Card.AllColorCard -> true
+ is Card.ColorCard -> card.color == lastColor
+ }
+ }
+ }
+ }
+ }
+
+ private val lastPlayerIndex: Int? get() {
+ val i = players.indexOf(lastPlayer)
+ return if (i == -1) {
+ null
+ } else {
+ i
+ }
+ }
+
+ private val nextPlayerIndex: Int get() {
+ val y =
+ if (direction == Direction.CLOCKWISE) {
+ +1
+ } else {
+ -1
+ }
+
+ return ((lastPlayerIndex ?: 0) + y) % players.size
+ }
+
+ val nextPlayer: Player = players.elementAt(nextPlayerIndex)
+
+ private val Player.currentIndex: Int get() = players.indexOf(this)
+
+ private fun Player.playerDiffIndex(nextPlayer: Player): Int =
+ if (direction == Direction.CLOCKWISE) {
+ nextPlayer.currentIndex + this.currentIndex
+ } else {
+ nextPlayer.currentIndex - this.currentIndex
+ }.let { it % players.size }
+
+ val Player.cardOnBoardIsForYou: Boolean get() {
+ if (lastCard == null) error("No card")
+ return this.playerDiffIndex(lastCard.player) == 1
+ }
+}
diff --git a/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt b/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt
index dc713c2..cdfa754 100644
--- a/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt
+++ b/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt
@@ -1,9 +1,28 @@
package eventDemo.shared.command
-import eventDemo.app.actions.playNewCard.PlayCardCommand
+import eventDemo.app.actions.playNewCard.GameCommand
+import eventDemo.libs.command.CommandStream
+import eventDemo.libs.command.CommandStreamChannel
import eventDemo.libs.command.CommandStreamInMemory
+import io.ktor.websocket.Frame
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.serialization.json.Json
/**
- * A stream to publish and read the played card command.
+ * A stream to publish and read the game command.
*/
-class GameCommandStream : CommandStreamInMemory()
+class GameCommandStreamInMemory : CommandStreamInMemory()
+
+/**
+ * A stream to publish and read the game command.
+ */
+class GameCommandStream(
+ incoming: ReceiveChannel,
+ outgoing: SendChannel,
+) : CommandStream by CommandStreamChannel(
+ incoming,
+ outgoing,
+ { Json.encodeToString(GameCommand.serializer(), it) },
+ { Json.decodeFromString(GameCommand.serializer(), it) },
+ )
diff --git a/src/main/kotlin/eventDemo/shared/entity/Card.kt b/src/main/kotlin/eventDemo/shared/entity/Card.kt
index bdb1e6c..c192458 100644
--- a/src/main/kotlin/eventDemo/shared/entity/Card.kt
+++ b/src/main/kotlin/eventDemo/shared/entity/Card.kt
@@ -1,13 +1,17 @@
package eventDemo.shared.entity
+import eventDemo.plugins.UUIDSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import java.util.UUID
/**
* A Play card
*/
@Serializable
sealed interface Card {
+ val id: UUID
+
/**
* The color of a card
*/
@@ -19,6 +23,10 @@ sealed interface Card {
Green,
}
+ sealed interface ColorCard : Card {
+ val color: Color
+ }
+
/**
* A play card with color and number
*/
@@ -26,8 +34,11 @@ sealed interface Card {
@SerialName("Simple")
data class NumericCard(
val number: Int,
- val color: Color,
- ) : Card
+ override val color: Color,
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : Card,
+ ColorCard
sealed interface Special : Card
@@ -37,8 +48,13 @@ sealed interface Card {
@Serializable
@SerialName("Reverse")
data class ReverseCard(
- val color: Color,
- ) : Special
+ override val color: Color,
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : Special,
+ ColorCard
+
+ sealed interface PassTurnCard : Card
/**
* A pass card to pass the turn of the next player.
@@ -46,8 +62,12 @@ sealed interface Card {
@Serializable
@SerialName("Pass")
data class PassCard(
- val color: Color,
- ) : Special
+ override val color: Color,
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : Special,
+ ColorCard,
+ PassTurnCard
/**
* A play card to force the next player to take 2 card and pass the turn.
@@ -55,24 +75,35 @@ sealed interface Card {
@Serializable
@SerialName("Plus2")
data class Plus2Card(
- val color: Color,
- ) : Special
+ override val color: Color,
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : Special,
+ ColorCard,
+ PassTurnCard
+
+ sealed interface AllColorCard : Card
/**
* A play card to force the next player to take 4 card and pass the turn.
*/
@Serializable
@SerialName("Plus4")
- data class Plus4Card(
- val nextColor: Color,
- ) : Special
+ class Plus4Card(
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : Special,
+ AllColorCard,
+ PassTurnCard
/**
* A play card to change the color.
*/
@Serializable
@SerialName("ChangeColor")
- data class ChangeColorCard(
- val nextColor: Color,
- ) : Special
+ class ChangeColorCard(
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : Special,
+ AllColorCard
}
diff --git a/src/main/kotlin/eventDemo/shared/entity/Deck.kt b/src/main/kotlin/eventDemo/shared/entity/Deck.kt
new file mode 100644
index 0000000..092834a
--- /dev/null
+++ b/src/main/kotlin/eventDemo/shared/entity/Deck.kt
@@ -0,0 +1,53 @@
+package eventDemo.shared.entity
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Deck(
+ val stack: Set = emptySet(),
+ val discard: Set = emptySet(),
+ val playersHands: List = emptyList(),
+) {
+ constructor(players: List) : this(playersHands = players.map { PlayerHand(it) })
+
+ fun putOneCardOnDiscard(): Deck {
+ val takenCard = stack.first()
+ val newStack = stack.filterNot { it != takenCard }.toSet()
+ return copy(stack = newStack)
+ }
+
+ fun take(n: Int): Pair> {
+ val takenCards = stack.take(n)
+ val newStack = stack.filterNot { takenCards.contains(it) }.toSet()
+ return Pair(copy(stack = newStack), takenCards)
+ }
+
+ companion object {
+ fun initHands(
+ players: Set,
+ handSize: Int = 7,
+ ): Deck {
+ val deck = new()
+ val playersHands = players.map { PlayerHand(it, deck.stack.take(handSize)) }
+ val allTakenCards = playersHands.flatMap { it.cards }
+ val newStack = deck.stack.filterNot { allTakenCards.contains(it) }.toSet()
+ return deck.copy(
+ stack = newStack,
+ playersHands = playersHands,
+ )
+ }
+
+ private fun new(): Deck =
+ listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green)
+ .flatMap { color ->
+ ((0..9) + (1..9)).map { Card.NumericCard(it, color) } +
+ (1..2).map { Card.Plus2Card(color) } +
+ (1..2).map { Card.ReverseCard(color) } +
+ (1..2).map { Card.PassCard(color) }
+ }.let {
+ (1..4).map { Card.Plus4Card() }
+ }.shuffled()
+ .toSet()
+ .let { Deck(it) }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/shared/entity/Game.kt b/src/main/kotlin/eventDemo/shared/entity/Game.kt
deleted file mode 100644
index 859bd8b..0000000
--- a/src/main/kotlin/eventDemo/shared/entity/Game.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package eventDemo.shared.entity
-
-import eventDemo.shared.GameId
-import kotlinx.serialization.Serializable
-
-/**
- * Represent a Game
- */
-@Serializable
-data class Game(
- val id: GameId,
-) {
- companion object {
- fun new(): Game = Game(GameId())
- }
-}
diff --git a/src/main/kotlin/eventDemo/shared/entity/Player.kt b/src/main/kotlin/eventDemo/shared/entity/Player.kt
new file mode 100644
index 0000000..592b92a
--- /dev/null
+++ b/src/main/kotlin/eventDemo/shared/entity/Player.kt
@@ -0,0 +1,36 @@
+package eventDemo.shared.entity
+
+import eventDemo.libs.event.AggregateId
+import eventDemo.plugins.PlayerIdSerializer
+import eventDemo.plugins.UUIDSerializer
+import io.ktor.server.auth.Principal
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+@Serializable
+data class Player(
+ val name: String,
+ @Serializable(with = PlayerIdSerializer::class)
+ val id: PlayerId = PlayerId(UUID.randomUUID()),
+) : Principal {
+ constructor(id: String, name: String) : this(
+ name,
+ PlayerId(UUID.fromString(id)),
+ )
+
+ @JvmInline
+ value class PlayerId(
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : AggregateId {
+ override fun toString(): String = id.toString()
+ }
+}
+
+@Serializable
+data class PlayerHand(
+ val player: Player,
+ val cards: List = emptyList(),
+) {
+ val count = lazy { cards.count() }
+}
diff --git a/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt
index d66b152..4e84074 100644
--- a/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt
+++ b/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt
@@ -3,11 +3,14 @@ package eventDemo.shared.event
import eventDemo.libs.event.Event
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
-import eventDemo.shared.entity.Game
+import eventDemo.shared.entity.Deck
+import eventDemo.shared.entity.Player
+import kotlinx.serialization.Serializable
/**
- * An [Event] of a [Game].
+ * An [Event] of a Game.
*/
+@Serializable
sealed interface GameEvent : Event {
override val id: GameId
}
@@ -18,4 +21,59 @@ sealed interface GameEvent : Event {
data class CardIsPlayedEvent(
override val id: GameId,
val card: Card,
+ val player: Player,
+) : GameEvent
+
+/**
+ * An [Event] to represent a new player joining the game.
+ */
+data class NewPlayerEvent(
+ override val id: GameId,
+ val player: Player,
+) : GameEvent
+
+/**
+ * This [Event] is sent when a player is ready.
+ */
+data class PlayerReadyEvent(
+ override val id: GameId,
+ val player: Player,
+) : GameEvent
+
+/**
+ * This [Event] is sent when a player is ready.
+ */
+data class GameStartedEvent(
+ override val id: GameId,
+ val firstPlayer: Player,
+ val deck: Deck,
+) : GameEvent {
+ companion object {
+ fun new(
+ id: GameId,
+ players: Set,
+ ): GameStartedEvent =
+ GameStartedEvent(
+ id = id,
+ firstPlayer = players.random(),
+ deck = Deck.initHands(players).putOneCardOnDiscard(),
+ )
+ }
+}
+
+/**
+ * This [Event] is sent when a player can play.
+ */
+data class PlayerHavePassEvent(
+ override val id: GameId,
+ val player: Player,
+) : GameEvent
+
+/**
+ * This [Event] is sent when a player chose a color.
+ */
+data class PlayerChoseColorEvent(
+ override val id: GameId,
+ val player: Player,
+ val color: Card.Color,
) : GameEvent
diff --git a/src/main/kotlin/eventDemo/shared/event/GameEventBus.kt b/src/main/kotlin/eventDemo/shared/event/GameEventBus.kt
new file mode 100644
index 0000000..f6e0287
--- /dev/null
+++ b/src/main/kotlin/eventDemo/shared/event/GameEventBus.kt
@@ -0,0 +1,8 @@
+package eventDemo.shared.event
+
+import eventDemo.libs.event.EventBus
+import eventDemo.shared.GameId
+
+class GameEventBus(
+ bus: EventBus,
+) : EventBus by bus
diff --git a/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt b/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt
index a85d58f..453ed3e 100644
--- a/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt
+++ b/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt
@@ -1,9 +1,18 @@
package eventDemo.shared.event
-import eventDemo.libs.event.EventStreamInMemory
+import eventDemo.libs.event.EventBus
+import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId
/**
* A stream to publish and read the played card event.
*/
-class GameEventStream : EventStreamInMemory(GameEvent::class.java)
+class GameEventStream(
+ private val eventBus: EventBus,
+ private val m: EventStream,
+) : EventStream by m {
+ override fun publish(event: GameEvent) {
+ m.publish(event)
+ eventBus.publish(event)
+ }
+}
diff --git a/src/main/kotlin/eventDemo/shared/event/GameStateBuilder.kt b/src/main/kotlin/eventDemo/shared/event/GameStateBuilder.kt
new file mode 100644
index 0000000..fc42162
--- /dev/null
+++ b/src/main/kotlin/eventDemo/shared/event/GameStateBuilder.kt
@@ -0,0 +1,71 @@
+package eventDemo.shared.event
+
+import eventDemo.libs.event.EventStream
+import eventDemo.shared.GameId
+import eventDemo.shared.entity.Card
+
+fun GameId.buildStateFromEventStream(eventStream: EventStream): GameState =
+ buildStateFromEvents(
+ eventStream.readAll(this),
+ )
+
+private fun GameId.buildStateFromEvents(events: List): GameState =
+ events.fold(GameState(this)) { state: GameState, event: GameEvent ->
+ when (event) {
+ is CardIsPlayedEvent -> {
+ val direction =
+ when (event.card) {
+ is Card.ReverseCard -> state.direction.revert()
+ else -> state.direction
+ }
+
+ val color =
+ when (event.card) {
+ is Card.ColorCard -> event.card.color
+ else -> state.lastColor
+ }
+
+ state.copy(
+ lastPlayer = event.player,
+ direction = direction,
+ lastColor = color,
+ )
+ }
+
+ is NewPlayerEvent -> {
+ if (state.isReady) error("The game is already started")
+
+ state.copy(
+ players = state.players + event.player,
+ )
+ }
+
+ is PlayerReadyEvent -> {
+ state.copy(
+ readyPlayers = state.readyPlayers + event.player,
+ )
+ }
+
+ is PlayerHavePassEvent -> {
+ state.copy(
+ lastPlayer = event.player,
+ )
+ }
+
+ is PlayerChoseColorEvent -> {
+ state.copy(
+ lastColor = event.color,
+ )
+ }
+
+ is GameStartedEvent -> {
+ state.copy(
+ lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color,
+ lastCard = GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
+ lastPlayer = event.firstPlayer,
+ deck = event.deck,
+ isStarted = true,
+ )
+ }
+ }
+ }
diff --git a/src/test/kotlin/eventDemo/app/actions/CardTest.kt b/src/test/kotlin/eventDemo/app/actions/CardTest.kt
index e82ce33..0b108cb 100644
--- a/src/test/kotlin/eventDemo/app/actions/CardTest.kt
+++ b/src/test/kotlin/eventDemo/app/actions/CardTest.kt
@@ -3,6 +3,7 @@ package eventDemo.app.actions
import eventDemo.configure
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
+import eventDemo.shared.entity.Player
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEventStream
import io.kotest.core.spec.style.FunSpec
@@ -32,6 +33,7 @@ class CardTest :
val id = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue)
+ val player = Player(name = "Nikola")
httpClient()
.post("/game/$id/card") {
contentType(Json)
@@ -41,7 +43,7 @@ class CardTest :
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val eventStream = getKoin().get()
- assertEquals(CardIsPlayedEvent(id, card), eventStream.readLast(id))
+ assertEquals(CardIsPlayedEvent(id, card, player), eventStream.readLast(id))
}
}
}
@@ -53,12 +55,14 @@ class CardTest :
application {
stopKoin()
configure()
+
val eventStream by inject()
+ val player = Player(name = "Nikola")
eventStream.publish(
- CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow)),
- CardIsPlayedEvent(id, card),
+ CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player),
+ CardIsPlayedEvent(id, card, player),
// Other game
- CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow)),
+ CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow), player),
)
}