From f3ca94c97e1906844aa8d3137993acd736180ffa Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Tue, 4 Mar 2025 23:02:07 +0100 Subject: [PATCH] create bus and subscriber --- .editorconfig | 4 +- .run/ApplicationKt.run.xml | 17 --- build.gradle.kts | 7 +- src/main/kotlin/eventDemo/Configure.kt | 26 +++- src/main/kotlin/eventDemo/Init.kt | 14 -- .../app/actions/GameCommandHandler.kt | 73 +++++++++ .../GameEventPlayerNotificationSubscriber.kt | 22 +++ .../actions/GameEventReactionSubscriber.kt | 27 ++++ .../app/actions/playNewCard/HttpRoute.kt | 42 ++--- .../actions/playNewCard/PlayCardCommand.kt | 28 +++- .../playNewCard/PlayCardCommandHandler.kt | 30 ---- .../libs/command/CommandStreamChannel.kt | 97 ++++++++++++ .../libs/command/CommandStreamInMemory.kt | 21 +-- .../kotlin/eventDemo/libs/event/EventBus.kt | 7 + .../eventDemo/libs/event/EventBusInMemory.kt | 15 ++ .../eventDemo/libs/event/EventStream.kt | 5 +- .../libs/event/EventStreamInMemory.kt | 19 +-- src/main/kotlin/eventDemo/plugins/HTTP.kt | 2 +- src/main/kotlin/eventDemo/plugins/Koin.kt | 19 ++- src/main/kotlin/eventDemo/plugins/Routing.kt | 2 +- src/main/kotlin/eventDemo/plugins/Security.kt | 65 +++----- .../kotlin/eventDemo/plugins/Serialization.kt | 14 ++ src/main/kotlin/eventDemo/plugins/Sockets.kt | 41 +++-- .../kotlin/eventDemo/shared/FrameConverter.kt | 15 ++ src/main/kotlin/eventDemo/shared/GameId.kt | 9 +- src/main/kotlin/eventDemo/shared/GameState.kt | 143 ++++++++++++++++++ .../shared/command/GameCommandStream.kt | 25 ++- .../kotlin/eventDemo/shared/entity/Card.kt | 59 ++++++-- .../kotlin/eventDemo/shared/entity/Deck.kt | 53 +++++++ .../kotlin/eventDemo/shared/entity/Game.kt | 16 -- .../kotlin/eventDemo/shared/entity/Player.kt | 36 +++++ .../shared/event/CardIsPlayedEvent.kt | 62 +++++++- .../eventDemo/shared/event/GameEventBus.kt | 8 + .../eventDemo/shared/event/GameEventStream.kt | 13 +- .../shared/event/GameStateBuilder.kt | 71 +++++++++ .../kotlin/eventDemo/app/actions/CardTest.kt | 12 +- 36 files changed, 885 insertions(+), 234 deletions(-) delete mode 100644 .run/ApplicationKt.run.xml delete mode 100644 src/main/kotlin/eventDemo/Init.kt create mode 100644 src/main/kotlin/eventDemo/app/actions/GameCommandHandler.kt create mode 100644 src/main/kotlin/eventDemo/app/actions/GameEventPlayerNotificationSubscriber.kt create mode 100644 src/main/kotlin/eventDemo/app/actions/GameEventReactionSubscriber.kt delete mode 100644 src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt create mode 100644 src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt create mode 100644 src/main/kotlin/eventDemo/libs/event/EventBus.kt create mode 100644 src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt create mode 100644 src/main/kotlin/eventDemo/shared/FrameConverter.kt create mode 100644 src/main/kotlin/eventDemo/shared/GameState.kt create mode 100644 src/main/kotlin/eventDemo/shared/entity/Deck.kt delete mode 100644 src/main/kotlin/eventDemo/shared/entity/Game.kt create mode 100644 src/main/kotlin/eventDemo/shared/entity/Player.kt create mode 100644 src/main/kotlin/eventDemo/shared/event/GameEventBus.kt create mode 100644 src/main/kotlin/eventDemo/shared/event/GameStateBuilder.kt 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), ) }