diff --git a/.idea/dataSources/data_sources_history.xml b/.idea/dataSources/data_sources_history.xml
new file mode 100644
index 0000000..19fba65
--- /dev/null
+++ b/.idea/dataSources/data_sources_history.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ "
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5432/event-demo
+ master_key
+ event-demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index e78e74a..6c4a236 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,21 +1,19 @@
-@file:Suppress("PropertyName")
-
import org.jlleitschuh.gradle.ktlint.KtlintExtension
-val ktor_version: String by project
-val kotlin_version: String by project
-val kotlin_serialization_version: String by project
-val logback_version: String by project
-val koin_version: String by project
-val kotlin_logging_version: String by project
-val kotest_version: String by project
+val ktorVersion: Provider = providers.gradleProperty("ktor_version")
+val kotlinVersion: Provider = providers.gradleProperty("kotlin_version")
+val kotlinSerializationVersion: Provider = providers.gradleProperty("kotlin_serialization_version")
+val logbackVersion: Provider = providers.gradleProperty("logback_version")
+val koinVersion: Provider = providers.gradleProperty("koin_version")
+val kotlinLoggingVersion: Provider = providers.gradleProperty("kotlin_logging_version")
+val kotestVersion: Provider = providers.gradleProperty("kotest_version")
plugins {
application
kotlin("jvm") version "2.1.21"
id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
- id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
+ id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12"
}
@@ -29,7 +27,7 @@ application {
}
configure {
- version.set("1.5.0")
+ version.set("1.8.0")
}
ktlint {
reporters {
@@ -124,23 +122,25 @@ dependencies {
implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth")
- implementation("ch.qos.logback:logback-classic:$logback_version")
- implementation("io.insert-koin:koin-ktor:$koin_version")
- implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
- implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
+ implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
+ implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
+ implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
+ implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation("redis.clients:jedis:5.2.0")
- implementation("org.postgresql:postgresql:42.7.5")
+ implementation("org.postgresql:postgresql:42.7.13")
implementation("com.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0")
+ implementation("com.password4j:password4j:1.8.4")
// Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13")
- testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
- testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
- testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version")
- testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
+ testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
+ testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
+ testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
+ testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
testImplementation("io.mockk:mockk:1.13.17")
+ testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}
diff --git a/doc/files-structure.md b/doc/files-structure.md
new file mode 100644
index 0000000..5cd8a1d
--- /dev/null
+++ b/doc/files-structure.md
@@ -0,0 +1,67 @@
+# Exemple de structure
+
+Les couches, du plus interne au plus externe
+```
+Domain (le cœur, ne dépend de RIEN d'externe)
+↑
+Application (orchestre le Domain, ne connaît pas l'infra concrète)
+↑
+Infrastructure (WebSocket, DB, event store — dépend de tout le reste)
+```
+
+```
+src/
+└── contexts/
+ ├── auth/
+ └── ...
+ └── game/
+ ├── domain/ ← Le cœur métier, zéro dépendance externe
+ │ ├── game/
+ │ │ ├── Game.ts ← Aggregate Root
+ │ │ ├── Player.ts ← Entity interne
+ │ │ ├── Card.ts ← Entity
+ │ │ ├── Color.ts ← Value Object
+ │ │ ├── Deck.ts ← VO ou petite structure
+ │ │ └── errors/
+ │ │ ├── InvalidMoveError.ts
+ │ │ └── ColorChoiceRequiredError.ts
+ │ └── events/ ← Events de DOMAINE (internes)
+ │ ├── CardPlayed.ts
+ │ ├── CardDrawn.ts
+ │ ├── TurnPassed.ts
+ │ └── DomainEvent.ts ← interface/type de base
+ ├── application/ ← Orchestration, cas d'usage
+ │ ├── commands/ ← Les Commandes (intentions)
+ │ │ ├── PlayCardCommand.ts
+ │ │ └── DrawCardCommand.ts
+ │ ├── handlers/ ← Un handler par commande
+ │ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save
+ │ │ └── DrawCardHandler.ts
+ │ ├── projections/ ← LA LOGIQUE de construction des projections
+ │ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue
+ │ │ └── PlayerStatsProjector.kt
+ │ └── ports/ ← INTERFACES seulement (le "hexagone")
+ │ ├── GameRepository.ts ← interface, pas d'implémentation
+ │ ├── EventPublisher.ts ← interface, pas d'implémentation
+ │ └── ProjectionStore.kt ← interface, où lire/écrire la projection
+ ├── infrastructure/ ← Tout ce qui est technique/externe
+ │ ├── persistence/
+ │ │ ├── EventStoreGameRepository.ts ← implémente GameRepository
+ │ │ ├── EventStore.ts
+ │ │ ├── projections/
+ │ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée)
+ │ │ │ └── models/
+ │ │ │ └── GameSummaryView.kt ← structure de la vue elle-même
+ │ ├── websocket/
+ │ │ ├── WebSocketServer.ts
+ │ │ ├── connectionManager.ts ← Map>
+ │ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler
+ │ └── eventPublisher/
+ │ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast
+ └── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer)
+ ├── clientEvents/
+ │ ├── ClientEvent.ts ← types des events envoyés au front
+ │ └── toClientEvent.ts ← fonction de traduction domain event → client event
+ └── clientCommands/
+ └── parseIncomingCommand.ts ← valide/parse le message brut du client → Command
+```
\ No newline at end of file
diff --git a/migrations/events/V1__init_event_table.sql b/migrations/events/V1__init_game_event_table.sql
similarity index 75%
rename from migrations/events/V1__init_event_table.sql
rename to migrations/events/V1__init_game_event_table.sql
index 6811f49..7c8c93c 100644
--- a/migrations/events/V1__init_event_table.sql
+++ b/migrations/events/V1__init_game_event_table.sql
@@ -1,4 +1,5 @@
-create table event_stream (
+create schema game;
+create table game.game_event_stream (
id uuid not null primary key,
aggregate_id uuid not null,
version int not null,
diff --git a/migrations/events/V2__init_auth_event_table.sql b/migrations/events/V2__init_auth_event_table.sql
new file mode 100644
index 0000000..df2d9ab
--- /dev/null
+++ b/migrations/events/V2__init_auth_event_table.sql
@@ -0,0 +1,8 @@
+create schema auth;
+create table auth.user_event_stream (
+ id uuid not null primary key,
+ aggregate_id uuid not null,
+ version int not null,
+ data jsonb not null,
+ unique(aggregate_id, version)
+);
\ No newline at end of file
diff --git a/migrations/events/V3__init_auth_projection_table.sql b/migrations/events/V3__init_auth_projection_table.sql
new file mode 100644
index 0000000..075f90b
--- /dev/null
+++ b/migrations/events/V3__init_auth_projection_table.sql
@@ -0,0 +1,6 @@
+create table auth.user (
+ id uuid not null primary key,
+ username text not null,
+ unique(id),
+ unique(username)
+);
\ No newline at end of file
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt
deleted file mode 100644
index 386e7b3..0000000
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInMemory.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package eventDemo.adapter.infrastructure.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventStore
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.event.EventStore
-import eventDemo.libs.event.EventStoreInMemory
-
-/**
- * A stream to publish and read the played card event.
- */
-class GameEventStoreInMemory :
- GameEventStore,
- EventStore by EventStoreInMemory()
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt
deleted file mode 100644
index 395fe83..0000000
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventStoreInPostgresql.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package eventDemo.adapter.infrastructure.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventStore
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.event.EventStore
-import eventDemo.libs.event.EventStoreInPostgresql
-import kotlinx.serialization.json.Json
-import javax.sql.DataSource
-
-/**
- * A stream to publish and read the played card event.
- */
-class GameEventStoreInPostgresql(
- dataSource: DataSource,
-) : GameEventStore,
- EventStore by EventStoreInPostgresql(
- dataSource,
- { Json.encodeToString(it) },
- { Json.decodeFromString(it) },
- )
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt
deleted file mode 100644
index 5e2dd5a..0000000
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInMemory.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package eventDemo.adapter.infrastructure.event.projection
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameState
-import eventDemo.domain.event.projection.GameStateRepository
-import eventDemo.domain.event.projection.apply
-import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
-import io.github.oshai.kotlinlogging.withLoggingContext
-
-/**
- * Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
- */
-class GameStateRepositoryInMemory : GameStateRepository {
- private val projectionsRepository =
- ProjectionRepositoryInMemory(
- applyToProjection = GameState::apply,
- initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
- )
-
- fun subscribeToBus(
- projectionBus: GameProjectionBus,
- eventBus: GameEventBus,
- ) {
- // On new event was received, build projection and publish it to the projection bus
- eventBus.subscribe { event ->
- withLoggingContext("event" to event.toString()) {
- projectionsRepository
- .applyAndSave(event)
- .also { projectionBus.publish(it) }
- }
- }
- }
-
- /**
- * Get the [GameState].
- */
- override fun get(gameId: GameId): GameState =
- projectionsRepository.get(gameId)
-}
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt b/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt
deleted file mode 100644
index 3963b48..0000000
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameStateRepositoryInRedis.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package eventDemo.adapter.infrastructure.event.projection
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameState
-import eventDemo.domain.event.projection.GameStateRepository
-import eventDemo.domain.event.projection.apply
-import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
-import io.github.oshai.kotlinlogging.withLoggingContext
-import kotlinx.serialization.json.Json
-import redis.clients.jedis.UnifiedJedis
-
-/**
- * Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
- */
-class GameStateRepositoryInRedis(
- jedis: UnifiedJedis,
-) : GameStateRepository {
- private val projectionsRepository =
- ProjectionRepositoryInRedis(
- initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
- projectionClass = GameState::class,
- projectionToJson = { Json.encodeToString(GameState.serializer(), it) },
- jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) },
- applyToProjection = GameState::apply,
- jedis = jedis,
- )
-
- fun subscribeToBus(
- projectionBus: GameProjectionBus,
- eventBus: GameEventBus,
- ) {
- // On new event was received, build projection and publish it to the projection bus
- eventBus.subscribe { event ->
- withLoggingContext("event" to event.toString()) {
- projectionsRepository
- .applyAndSave(event)
- .also { projectionBus.publish(it) }
- }
- }
- }
-
- /**
- * Get the [GameState].
- */
- override fun get(gameId: GameId): GameState =
- projectionsRepository.get(gameId)
-}
diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt
deleted file mode 100644
index b7d0b1f..0000000
--- a/src/main/kotlin/eventDemo/adapter/presenter/query/GameCommandRouteWebSocket.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package eventDemo.adapter.presenter.query
-
-import eventDemo.domain.command.GameCommandHandler
-import eventDemo.domain.command.command.GameCommand
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener
-import eventDemo.domain.notification.Notification
-import eventDemo.libs.fromFrameChannel
-import eventDemo.libs.toObjectChannel
-import io.github.oshai.kotlinlogging.withLoggingContext
-import io.ktor.server.auth.authenticate
-import io.ktor.server.routing.Route
-import io.ktor.server.websocket.DefaultWebSocketServerSession
-import io.ktor.server.websocket.webSocket
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.channels.ReceiveChannel
-import kotlinx.coroutines.channels.SendChannel
-import kotlinx.coroutines.channels.trySendBlocking
-import kotlinx.coroutines.launch
-import java.util.UUID
-
-@DelicateCoroutinesApi
-fun Route.gameWebSocket(
- playerNotificationListener: PlayerNotificationListener,
- commandHandler: GameCommandHandler,
-) {
- authenticate {
- webSocket("/games/new") {
- runWebSocket(GameId(), commandHandler, playerNotificationListener)
- }
-
- webSocket("/games/{id}") {
- val gameId = GameId(UUID.fromString(call.parameters["id"]!!))
- runWebSocket(gameId, commandHandler, playerNotificationListener)
- }
- }
-}
-
-@DelicateCoroutinesApi
-private fun DefaultWebSocketServerSession.runWebSocket(
- gameId: GameId,
- commandHandler: GameCommandHandler,
- playerNotificationListener: PlayerNotificationListener,
-) {
- val currentPlayer = call.getPlayerCredentials()
- val incomingFrameChannel: ReceiveChannel = toObjectChannel(incoming)
- val outgoingFrameChannel: SendChannel = fromFrameChannel(outgoing)
- withLoggingContext("currentPlayer" to currentPlayer.toString()) {
- val notificationListener =
- playerNotificationListener.startListening(
- currentPlayer,
- gameId,
- ) { outgoingFrameChannel.trySendBlocking(it) }
-
- // TODO change GlobalScope
- GlobalScope.launch {
- commandHandler.handleIncomingPlayerCommands(
- currentPlayer,
- gameId,
- incomingFrameChannel,
- outgoingFrameChannel,
- )
- notificationListener.close()
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt
deleted file mode 100644
index 766996a..0000000
--- a/src/main/kotlin/eventDemo/adapter/presenter/query/GetPlayerCredentials.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package eventDemo.adapter.presenter.query
-
-import eventDemo.domain.entity.Player
-import io.ktor.server.application.ApplicationCall
-import io.ktor.server.auth.jwt.JWTPrincipal
-import io.ktor.server.auth.principal
-
-internal fun ApplicationCall.getPlayerCredentials() =
- principal()!!.run {
- Player(
- id = payload.getClaim("playerid").asString(),
- name = payload.getClaim("username").asString(),
- )
- }
diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt b/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt
deleted file mode 100644
index aa5e161..0000000
--- a/src/main/kotlin/eventDemo/adapter/presenter/query/ReadTheGameState.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package eventDemo.adapter.presenter.query
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.projection.GameStateRepository
-import eventDemo.configuration.serializer.GameIdSerializer
-import io.ktor.http.HttpStatusCode
-import io.ktor.resources.Resource
-import io.ktor.server.auth.authenticate
-import io.ktor.server.resources.get
-import io.ktor.server.response.respond
-import io.ktor.server.routing.Route
-import kotlinx.serialization.Serializable
-
-@Serializable
-@Resource("/games/{id}")
-class Game(
- @Serializable(with = GameIdSerializer::class)
- val id: GameId,
-) {
- @Serializable
- @Resource("card/last")
- class Card(
- val game: Game,
- )
-
- @Serializable
- @Resource("state")
- class State(
- val game: Game,
- )
-}
-
-/**
- * API routes to read the game state.
- */
-fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
- authenticate {
- // Read the last played card on the game.
- get { body ->
- gameStateRepository
- .get(body.game.id)
- .cardOnCurrentStack
- ?.let { call.respond(it) }
- ?: call.response.status(HttpStatusCode.BadRequest)
- }
-
- // Read the last played card on the game.
- get { body ->
- val state = gameStateRepository.get(body.game.id)
- call.respond(state)
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/configuration/Configuration.kt b/src/main/kotlin/eventDemo/configuration/Configuration.kt
new file mode 100644
index 0000000..0ee7d73
--- /dev/null
+++ b/src/main/kotlin/eventDemo/configuration/Configuration.kt
@@ -0,0 +1,46 @@
+package eventDemo.configuration
+
+import io.ktor.server.config.ApplicationConfig
+
+data class Configuration(
+ val redisUrl: String,
+ val jwtSecret: String,
+ val postgresql: Postgresql,
+ val rabbitmq: RabbitMQ,
+) {
+ data class Postgresql(
+ val url: String,
+ val username: String,
+ val password: String,
+ )
+
+ data class RabbitMQ(
+ val url: String,
+ val port: Int,
+ val username: String,
+ val password: String,
+ )
+}
+
+val ApplicationConfig.configuration
+ get() =
+ Configuration(
+ redisUrl = getProperty("redis.url"),
+ jwtSecret = getProperty("jwt.secret"),
+ postgresql =
+ Configuration.Postgresql(
+ url = getProperty("postgresql.url"),
+ username = getProperty("postgresql.username"),
+ password = getProperty("postgresql.password"),
+ ),
+ rabbitmq =
+ Configuration.RabbitMQ(
+ url = getProperty("rabbitmq.url"),
+ port = getProperty("rabbitmq.port").toInt(),
+ username = getProperty("rabbitmq.username"),
+ password = getProperty("rabbitmq.password"),
+ ),
+ )
+
+private fun ApplicationConfig.getProperty(path: String): String =
+ propertyOrNull(path)?.getString() ?: error("You must set the $path")
diff --git a/src/main/kotlin/eventDemo/configuration/Configure.kt b/src/main/kotlin/eventDemo/configuration/Configure.kt
deleted file mode 100644
index fb45a18..0000000
--- a/src/main/kotlin/eventDemo/configuration/Configure.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package eventDemo.configuration
-
-import eventDemo.configuration.domain.configureGameListener
-import eventDemo.configuration.ktor.configureHttpRouting
-import eventDemo.configuration.ktor.configureKoin
-import eventDemo.configuration.ktor.configureSecurity
-import eventDemo.configuration.ktor.configureSerialization
-import eventDemo.configuration.ktor.configureWebSockets
-import eventDemo.configuration.route.declareHttpGameRoute
-import eventDemo.configuration.route.declareWebSocketsGameRoute
-import io.ktor.server.application.Application
-import org.koin.ktor.ext.get
-import org.koin.ktor.ext.getKoin
-
-fun Application.configure() {
- configureKoin()
-
- configureSecurity()
-
- configureSerialization()
-
- configureWebSockets()
- declareWebSocketsGameRoute(get(), get())
-
- configureHttpRouting()
- declareHttpGameRoute()
-
- getKoin().configureGameListener()
-}
diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt
new file mode 100644
index 0000000..fdeb0ba
--- /dev/null
+++ b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt
@@ -0,0 +1,14 @@
+package eventDemo.configuration
+
+import eventDemo.contexts.auth.infrastructure.configure.configureAuthDi
+import eventDemo.contexts.game.infrastructure.configuration.injections.application.configureGameDIApplication
+import eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure.configureGameDIInfrastructure
+import org.koin.dsl.module
+
+fun appKoinModule(config: Configuration) =
+ module {
+ configureDIDataSource(config)
+ configureAuthDi()
+ configureGameDIInfrastructure()
+ configureGameDIApplication()
+ }
diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt
new file mode 100644
index 0000000..9a7c972
--- /dev/null
+++ b/src/main/kotlin/eventDemo/configuration/ConfigureDIDataSources.kt
@@ -0,0 +1,55 @@
+package eventDemo.configuration
+
+import com.rabbitmq.client.ConnectionFactory
+import com.zaxxer.hikari.HikariConfig
+import com.zaxxer.hikari.HikariDataSource
+import org.koin.core.module.Module
+import org.koin.core.scope.Scope
+import org.koin.core.scope.ScopeCallback
+import org.koin.dsl.bind
+import redis.clients.jedis.JedisPooled
+import redis.clients.jedis.UnifiedJedis
+import javax.sql.DataSource
+
+fun Module.configureDIDataSource(config: Configuration) {
+ // PostgreSQL (for EventStore)
+ single {
+ hikariDataSource(config)
+ .apply {
+ registerCallback(
+ object : ScopeCallback {
+ override fun onScopeClose(scope: Scope) {
+ close()
+ }
+ },
+ )
+ }
+ } bind DataSource::class
+
+ // Redis (for Projections)
+ single {
+ JedisPooled(config.redisUrl)
+ } bind UnifiedJedis::class
+
+ // RabbitMQ (for EventBus)
+ factory {
+ ConnectionFactory().apply {
+ host = config.rabbitmq.url
+ port = config.rabbitmq.port
+ username = config.rabbitmq.username
+ password = config.rabbitmq.password
+ }
+ }
+}
+
+private fun hikariDataSource(config: Configuration): HikariDataSource =
+ HikariConfig()
+ .apply {
+ jdbcUrl = config.postgresql.url
+ username = config.postgresql.username
+ password = config.postgresql.password
+ maximumPoolSize = 10
+ minimumIdle = 10
+ }.let {
+ HikariDataSource(it)
+ }
diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt b/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt
new file mode 100644
index 0000000..1fb671d
--- /dev/null
+++ b/src/main/kotlin/eventDemo/configuration/ConfigureKoin.kt
@@ -0,0 +1,16 @@
+package eventDemo.configuration
+
+import io.ktor.server.application.Application
+import io.ktor.server.application.install
+import org.koin.ktor.plugin.Koin
+import org.koin.logger.slf4jLogger
+
+fun Application.configureKoin() {
+ install(Koin) {
+ slf4jLogger()
+
+ modules(
+ appKoinModule(environment.config.configuration),
+ )
+ }
+}
diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt b/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt
new file mode 100644
index 0000000..bd38479
--- /dev/null
+++ b/src/main/kotlin/eventDemo/configuration/ConfigureKtor.kt
@@ -0,0 +1,11 @@
+package eventDemo.configuration
+
+import eventDemo.contexts.auth.infrastructure.configure.configureAuth
+import eventDemo.contexts.game.infrastructure.configuration.ktor.configureUno
+import io.ktor.server.application.Application
+
+fun Application.configure() {
+ configureKoin()
+ configureAuth()
+ configureUno()
+}
diff --git a/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt
deleted file mode 100644
index c49bcfe..0000000
--- a/src/main/kotlin/eventDemo/configuration/domain/ConfigureGameListener.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package eventDemo.configuration.domain
-
-import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis
-import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis
-import eventDemo.domain.command.GameCommandHandler
-import eventDemo.domain.event.projection.projectionListener.ReactionListener
-import org.koin.core.Koin
-
-fun Koin.configureGameListener() {
- get()
- .subscribeToBus(get())
-
- get()
- .subscribeToBus(get(), get())
-
- get()
- .subscribeToBus(get(), get())
-
- get()
- .subscribeToBus(get())
-}
diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt
deleted file mode 100644
index a62be93..0000000
--- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDI.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package eventDemo.configuration.injection
-
-import org.koin.dsl.module
-
-fun appKoinModule(config: Configuration) =
- module {
- configureDIBusiness()
- configureDIInfrastructure(config)
- configureDILibs()
- configureDICommandActions()
- }
-
-data class Configuration(
- val redisUrl: String,
- val postgresql: Postgresql,
- val rabbitmq: RabbitMQ,
-) {
- data class Postgresql(
- val url: String,
- val username: String,
- val password: String,
- )
-
- data class RabbitMQ(
- val url: String,
- val port: Int,
- val username: String,
- val password: String,
- )
-}
diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt
deleted file mode 100644
index 9060c2b..0000000
--- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIAction.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package eventDemo.configuration.injection
-
-import eventDemo.domain.command.action.ICantPlay
-import eventDemo.domain.command.action.IWantToJoinTheGame
-import eventDemo.domain.command.action.IWantToPlayCard
-import eventDemo.domain.command.action.IamReadyToPlay
-import org.koin.core.module.Module
-import org.koin.core.module.dsl.singleOf
-
-/**
- * Configure all actions
- */
-fun Module.configureDICommandActions() {
- singleOf(::IWantToPlayCard)
- singleOf(::IamReadyToPlay)
- singleOf(::IWantToJoinTheGame)
- singleOf(::ICantPlay)
-}
diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt
deleted file mode 100644
index 8364dd0..0000000
--- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIBusiness.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package eventDemo.configuration.injection
-
-import eventDemo.domain.command.GameCommandActionRunner
-import eventDemo.domain.command.GameCommandHandler
-import eventDemo.domain.event.GameEventHandler
-import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener
-import eventDemo.domain.event.projection.projectionListener.ReactionListener
-import org.koin.core.module.Module
-import org.koin.core.module.dsl.singleOf
-
-fun Module.configureDIBusiness() {
- single {
- GameCommandHandler(get(), get(), get(), get())
- }
- singleOf(::GameEventHandler)
- singleOf(::GameCommandActionRunner)
- singleOf(::PlayerNotificationListener)
- singleOf(::ReactionListener)
-}
diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt
deleted file mode 100644
index e99894e..0000000
--- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package eventDemo.configuration.injection
-
-import com.rabbitmq.client.ConnectionFactory
-import com.zaxxer.hikari.HikariConfig
-import com.zaxxer.hikari.HikariDataSource
-import eventDemo.adapter.infrastructure.event.GameEventBusInRabbinMQ
-import eventDemo.adapter.infrastructure.event.GameEventStoreInPostgresql
-import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis
-import eventDemo.adapter.infrastructure.event.projection.GameProjectionBusInRabbitMQ
-import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.GameEventStore
-import eventDemo.domain.event.projection.GameListRepository
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameStateRepository
-import org.koin.core.module.Module
-import org.koin.core.module.dsl.singleOf
-import org.koin.core.scope.Scope
-import org.koin.core.scope.ScopeCallback
-import org.koin.dsl.bind
-import redis.clients.jedis.JedisPooled
-import redis.clients.jedis.UnifiedJedis
-import javax.sql.DataSource
-
-fun Module.configureDIInfrastructure(config: Configuration) {
- // Postgresql config
- single {
- JedisPooled(config.redisUrl)
- } bind UnifiedJedis::class
-
- single {
- HikariConfig()
- .apply {
- jdbcUrl = config.postgresql.url
- username = config.postgresql.username
- password = config.postgresql.password
- maximumPoolSize = 10
- minimumIdle = 10
- }.let {
- HikariDataSource(it)
- }.also { datasource ->
- registerCallback(
- object : ScopeCallback {
- override fun onScopeClose(scope: Scope) {
- datasource.close()
- }
- },
- )
- }
- } bind DataSource::class
-
- // RabbitMQ config
- factory {
- ConnectionFactory().apply {
- host = config.rabbitmq.url
- port = config.rabbitmq.port
- username = config.rabbitmq.username
- password = config.rabbitmq.password
- }
- }
-
- singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
- singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
- singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class
-
- single {
- GameStateRepositoryInRedis(get())
- } bind GameStateRepository::class
-
- single {
- GameListRepositoryInRedis(get())
- } bind GameListRepository::class
-}
diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt
deleted file mode 100644
index d13a1f8..0000000
--- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDILibs.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package eventDemo.configuration.injection
-
-import eventDemo.libs.event.VersionBuilder
-import eventDemo.libs.event.VersionBuilderLocal
-import org.koin.core.module.Module
-import org.koin.core.module.dsl.singleOf
-import org.koin.dsl.bind
-
-fun Module.configureDILibs() {
- singleOf(::VersionBuilderLocal) bind VersionBuilder::class
-}
diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt b/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt
deleted file mode 100644
index 870077b..0000000
--- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureKoin.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package eventDemo.configuration.ktor
-
-import eventDemo.configuration.injection.Configuration
-import eventDemo.configuration.injection.appKoinModule
-import io.ktor.server.application.Application
-import io.ktor.server.application.install
-import io.ktor.server.config.ApplicationConfig
-import org.koin.ktor.plugin.Koin
-import org.koin.logger.slf4jLogger
-
-fun Application.configureKoin() {
- install(Koin) {
- slf4jLogger()
-
- modules(
- appKoinModule(
- environment.config.configuration(),
- ),
- )
- }
-}
-
-fun ApplicationConfig.configuration() =
- Configuration(
- redisUrl = getProperty("redis.url"),
- postgresql =
- Configuration.Postgresql(
- url = getProperty("postgresql.url"),
- username = getProperty("postgresql.username"),
- password = getProperty("postgresql.password"),
- ),
- rabbitmq =
- Configuration.RabbitMQ(
- url = getProperty("rabbitmq.url"),
- port = getProperty("rabbitmq.port").toInt(),
- username = getProperty("rabbitmq.username"),
- password = getProperty("rabbitmq.password"),
- ),
- )
-
-private fun ApplicationConfig.getProperty(path: String): String =
- propertyOrNull(path)?.getString() ?: error("You must set the $path")
diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt
deleted file mode 100644
index e37e7d3..0000000
--- a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package eventDemo.configuration.route
-
-import eventDemo.adapter.presenter.query.readGamesList
-import eventDemo.adapter.presenter.query.readTheGameState
-import io.ktor.server.application.Application
-import io.ktor.server.routing.routing
-import org.koin.ktor.ext.get
-
-fun Application.declareHttpGameRoute() {
- routing {
- readTheGameState(this@declareHttpGameRoute.get())
- readGamesList(this@declareHttpGameRoute.get())
- }
-}
diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt
deleted file mode 100644
index 573f1f3..0000000
--- a/src/main/kotlin/eventDemo/configuration/route/DeclareWebSocketsGameRoute.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package eventDemo.configuration.route
-
-import eventDemo.adapter.presenter.query.gameWebSocket
-import eventDemo.domain.command.GameCommandHandler
-import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener
-import io.ktor.server.application.Application
-import io.ktor.server.routing.routing
-import kotlinx.coroutines.DelicateCoroutinesApi
-
-@OptIn(DelicateCoroutinesApi::class)
-fun Application.declareWebSocketsGameRoute(
- playerNotificationListener: PlayerNotificationListener,
- commandHandler: GameCommandHandler,
-) {
- routing {
- gameWebSocket(playerNotificationListener, commandHandler)
- }
-}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt
new file mode 100644
index 0000000..e77e7bf
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserEventStoreRepository.kt
@@ -0,0 +1,24 @@
+package eventDemo.contexts.auth.application.eventStores
+
+import eventDemo.contexts.auth.application.ports.UserEventStore
+import eventDemo.contexts.auth.domain.User
+import eventDemo.sharedKernel.UserId
+
+class UserEventStoreRepository(
+ val eventStore: UserEventStore,
+) : UserRepository {
+ override fun get(id: UserId): User? {
+ val events =
+ eventStore
+ .getStream(id)
+ .readAll()
+ if (events.isEmpty()) {
+ return null
+ }
+ return events.let { User.loadFromHistory(it) }
+ }
+
+ override fun save(user: User) {
+ eventStore.publish(user.recordedEvents)
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt
new file mode 100644
index 0000000..4f36eb1
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/application/eventStores/UserRepository.kt
@@ -0,0 +1,10 @@
+package eventDemo.contexts.auth.application.eventStores
+
+import eventDemo.contexts.auth.domain.User
+import eventDemo.sharedKernel.UserId
+
+interface UserRepository {
+ fun get(id: UserId): User?
+
+ fun save(user: User)
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt
new file mode 100644
index 0000000..e576aad
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserEventStore.kt
@@ -0,0 +1,7 @@
+package eventDemo.contexts.auth.application.ports
+
+import eventDemo.contexts.auth.domain.events.UserEvent
+import eventDemo.libs.eventSource.eventStore.EventStore
+import eventDemo.sharedKernel.UserId
+
+interface UserEventStore : EventStore
diff --git a/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt
new file mode 100644
index 0000000..9f05c56
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/application/ports/UserProjectionRepository.kt
@@ -0,0 +1,14 @@
+package eventDemo.contexts.auth.application.ports
+
+import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
+
+interface UserProjectionRepository {
+ fun getByUsername(username: String): UserProjection?
+
+ fun save(user: UserProjection)
+
+ fun getUserIfPasswordIsValid(
+ username: String,
+ rawPassword: String,
+ ): UserProjection?
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt
new file mode 100644
index 0000000..cf7fe68
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/domain/User.kt
@@ -0,0 +1,39 @@
+package eventDemo.contexts.auth.domain
+
+import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent
+import eventDemo.contexts.auth.domain.events.UserEvent
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class User(
+ val id: UserId,
+ val username: String,
+ val password: String,
+ val version: Int,
+) {
+ val recordedEvents: Set = emptySet()
+
+ companion object {
+ fun createNewUser(
+ username: String,
+ password: String,
+ ): User =
+ apply(NewUserCreatedEvent(username, password, version = 1))
+
+ fun apply(event: NewUserCreatedEvent): User =
+ User(
+ id = event.aggregateId,
+ username = event.username,
+ password = event.password,
+ version = event.version,
+ )
+
+ fun loadFromHistory(events: Set): User? =
+ events.fold(null as User?) { acc, event ->
+ when (event) {
+ is NewUserCreatedEvent -> apply(event)
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt
new file mode 100644
index 0000000..49f5ca8
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/domain/events/NewUserCreatedEvent.kt
@@ -0,0 +1,16 @@
+package eventDemo.contexts.auth.domain.events
+
+import eventDemo.libs.eventSource.EventId
+import eventDemo.sharedKernel.UserId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+
+class NewUserCreatedEvent(
+ val username: String,
+ val password: String,
+ override val version: Int,
+ override val createdAt: Instant = Clock.System.now(),
+ override val aggregateId: UserId = UserId(),
+) : UserEvent {
+ override val eventId: EventId = EventId()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt b/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt
new file mode 100644
index 0000000..7cc3199
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/domain/events/UserEvent.kt
@@ -0,0 +1,6 @@
+package eventDemo.contexts.auth.domain.events
+
+import eventDemo.libs.eventSource.Event
+import eventDemo.sharedKernel.UserId
+
+sealed interface UserEvent : Event
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt
new file mode 100644
index 0000000..726ccd8
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/HashPassword.kt
@@ -0,0 +1,13 @@
+package eventDemo.contexts.auth.infrastructure
+
+import com.password4j.Hash
+import com.password4j.Password
+
+internal fun hashPassword(password: String): Hash =
+ Password.hash(password).addRandomSalt().withArgon2()
+
+internal fun checkPassword(
+ password: String,
+ hash: Hash,
+): Boolean =
+ Password.check(password, hash)
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt
new file mode 100644
index 0000000..c52bb3e
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuth.kt
@@ -0,0 +1,8 @@
+package eventDemo.contexts.auth.infrastructure.configure
+
+import io.ktor.server.application.Application
+
+fun Application.configureAuth() {
+ configureKtorAuth()
+ configureAuthRoutes()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt
new file mode 100644
index 0000000..82b1d3f
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthDI.kt
@@ -0,0 +1,17 @@
+package eventDemo.contexts.auth.infrastructure.configure
+
+import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
+import eventDemo.contexts.auth.application.eventStores.UserRepository
+import eventDemo.contexts.auth.application.ports.UserEventStore
+import eventDemo.contexts.auth.application.ports.UserProjectionRepository
+import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInPostgresql
+import eventDemo.contexts.auth.infrastructure.persistence.projection.UsedProjectionRepositoryInPostgresql
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.bind
+
+fun Module.configureAuthDi() {
+ singleOf(::UserEventStoreRepository) bind UserRepository::class
+ singleOf(::UserEventStoreInPostgresql) bind UserEventStore::class
+ singleOf(::UsedProjectionRepositoryInPostgresql) bind UserProjectionRepository::class
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt
new file mode 100644
index 0000000..b2cc987
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureAuthRoutes.kt
@@ -0,0 +1,20 @@
+package eventDemo.contexts.auth.infrastructure.configure
+
+import eventDemo.configuration.configuration
+import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
+import eventDemo.contexts.auth.application.eventStores.UserRepository
+import eventDemo.contexts.auth.application.ports.UserProjectionRepository
+import eventDemo.contexts.auth.infrastructure.rest.createUserRoute
+import eventDemo.contexts.auth.infrastructure.rest.loginRoute
+import io.ktor.server.application.Application
+import io.ktor.server.routing.routing
+import org.koin.ktor.ext.get
+
+fun Application.configureAuthRoutes() {
+ val userRepository = get()
+ val userProjectionRepository = get()
+ routing {
+ createUserRoute(userRepository)
+ loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository)
+ }
+}
diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt
similarity index 56%
rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt
rename to src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt
index 88a7fd2..1f8d669 100644
--- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureAuth.kt
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/configure/ConfigureKtorAuth.kt
@@ -1,8 +1,11 @@
-package eventDemo.configuration.ktor
+package eventDemo.contexts.auth.infrastructure.configure
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
-import eventDemo.domain.entity.Player
+import eventDemo.configuration.configuration
+import eventDemo.contexts.auth.domain.User
+import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
+import eventDemo.sharedKernel.UserId
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.auth.authentication
@@ -13,12 +16,10 @@ import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
import java.util.Date
+import java.util.UUID
-private const val JWT_ISSUER = "PlayCardGame"
-
-fun Application.configureSecurity() {
- val jwtSecret = environment.config.propertyOrNull("jwt.secret")?.getString() ?: error("You must set a jwt secret")
-
+fun Application.configureKtorAuth() {
+ val jwtSecret = environment.config.configuration.jwtSecret
authentication {
jwt {
realm = "Play card game"
@@ -29,7 +30,11 @@ fun Application.configureSecurity() {
.build(),
)
validate { credential ->
- if (credential.payload.getClaim("username").asString() != "") {
+ if (credential.payload
+ .getClaim("username")
+ .asString()
+ .isNotEmpty()
+ ) {
JWTPrincipal(credential.payload)
} else {
null
@@ -40,22 +45,25 @@ fun Application.configureSecurity() {
}
}
}
-
- routing {
- post("login/{username}") {
- val username = call.parameters["username"]!!
- val player = Player(name = username)
-
- call.respond(hashMapOf("token" to player.makeJwt(jwtSecret)))
- }
- }
}
-fun Player.makeJwt(jwtSecret: String): String =
+private const val JWT_ISSUER = "PlayCardGame"
+
+fun UserProjection.makeJwt(jwtSecret: String): String =
+ makeJwt(jwtSecret, id, username)
+
+fun User.makeJwt(jwtSecret: String): String =
+ makeJwt(jwtSecret, id, username)
+
+fun makeJwt(
+ jwtSecret: String,
+ id: UserId,
+ username: String,
+): String =
JWT
.create()
.withIssuer(JWT_ISSUER)
- .withClaim("username", name)
- .withPayload(Json.encodeToString(this))
+ .withClaim("username", username)
+ .withClaim("userid", id.toString())
.withExpiresAt(Date(System.currentTimeMillis() + 60000))
.sign(Algorithm.HMAC256(jwtSecret))
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt
new file mode 100644
index 0000000..f0248c0
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInMemory.kt
@@ -0,0 +1,14 @@
+package eventDemo.contexts.auth.infrastructure.persistence.eventStore
+
+import eventDemo.contexts.auth.application.ports.UserEventStore
+import eventDemo.contexts.auth.domain.events.UserEvent
+import eventDemo.libs.eventSource.eventStore.EventStore
+import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
+import eventDemo.sharedKernel.UserId
+
+/**
+ * A stream to publish and read the user events.
+ */
+class UserEventStoreInMemory :
+ UserEventStore,
+ EventStore by EventStoreInMemory()
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt
new file mode 100644
index 0000000..7ea4c99
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/eventStore/UserEventStoreInPostgresql.kt
@@ -0,0 +1,22 @@
+package eventDemo.contexts.auth.infrastructure.persistence.eventStore
+
+import eventDemo.contexts.auth.application.ports.UserEventStore
+import eventDemo.contexts.auth.domain.events.UserEvent
+import eventDemo.libs.eventSource.eventStore.EventStore
+import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.json.Json
+import javax.sql.DataSource
+
+/**
+ * A stream to publish and read the user events.
+ */
+class UserEventStoreInPostgresql(
+ dataSource: DataSource,
+) : UserEventStore,
+ EventStore by EventStoreInPostgresql(
+ dataSource,
+ { Json.encodeToString(it) },
+ { Json.decodeFromString(it) },
+ "auth.user_event_store",
+ )
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UsedProjectionRepositoryInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UsedProjectionRepositoryInPostgresql.kt
new file mode 100644
index 0000000..6c1e208
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UsedProjectionRepositoryInPostgresql.kt
@@ -0,0 +1,61 @@
+package eventDemo.contexts.auth.infrastructure.persistence.projection
+
+import eventDemo.contexts.auth.application.ports.UserProjectionRepository
+import eventDemo.contexts.auth.infrastructure.checkPassword
+import eventDemo.contexts.auth.infrastructure.hashPassword
+import eventDemo.sharedKernel.UserId
+import java.util.UUID
+import javax.sql.DataSource
+
+class UsedProjectionRepositoryInPostgresql(
+ val dataSource: DataSource,
+) : UserProjectionRepository {
+ override fun getByUsername(username: String): UserProjection? =
+ dataSource.connection
+ .prepareStatement(
+ """
+ select id, username
+ from auth."user"
+ where id = ?;
+ """.trimIndent(),
+ ).use {
+ it.setObject(1, username)
+ it.executeQuery()
+ }.use { resultSet ->
+ if (resultSet.next()) {
+ UserProjection(
+ id = UserId(UUID.fromString(resultSet.getString("id"))),
+ username = resultSet.getString("username"),
+ password = resultSet.getString("password"),
+ )
+ } else {
+ null
+ }
+ }
+
+ override fun save(user: UserProjection) {
+ dataSource.connection.use { connection ->
+ connection
+ .prepareStatement(
+ """
+ insert into auth.user (id, username)
+ values (?, ?)
+ """.trimIndent(),
+ ).use {
+ it.setObject(1, user.id)
+ it.setString(2, user.username)
+ it.executeUpdate()
+ }
+ }
+ }
+
+ override fun getUserIfPasswordIsValid(
+ username: String,
+ rawPassword: String,
+ ): UserProjection? {
+ val user = getByUsername(username) ?: return null
+ val isValid = checkPassword(rawPassword, hashPassword(user.password))
+ if (!isValid) return null
+ return user
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt
new file mode 100644
index 0000000..310d2f6
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/persistence/projection/UserProjection.kt
@@ -0,0 +1,9 @@
+package eventDemo.contexts.auth.infrastructure.persistence.projection
+
+import eventDemo.sharedKernel.UserId
+
+data class UserProjection(
+ val id: UserId,
+ val username: String,
+ val password: String,
+)
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt
new file mode 100644
index 0000000..c994ced
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/LoginRoute.kt
@@ -0,0 +1,24 @@
+package eventDemo.contexts.auth.infrastructure.rest
+
+import eventDemo.contexts.auth.application.ports.UserProjectionRepository
+import eventDemo.contexts.auth.infrastructure.configure.makeJwt
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+import io.ktor.server.routing.post
+
+fun Route.loginRoute(
+ jwtSecret: String,
+ userProjectionRepository: UserProjectionRepository,
+) {
+ post("login/{username}") {
+ val username = call.parameters["username"]!!
+ val rawPassword = call.parameters["password"]!!
+
+ val userProjection =
+ userProjectionRepository.getUserIfPasswordIsValid(username, rawPassword)
+ ?: return@post call.respond(HttpStatusCode.BadRequest)
+
+ call.respond(hashMapOf("token" to userProjection.makeJwt(jwtSecret)))
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt
new file mode 100644
index 0000000..a9f46b0
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/auth/infrastructure/rest/UserCreateRoute.kt
@@ -0,0 +1,43 @@
+package eventDemo.contexts.auth.infrastructure.rest
+
+import com.password4j.Hash
+import com.password4j.Password
+import eventDemo.contexts.auth.application.eventStores.UserRepository
+import eventDemo.contexts.auth.domain.User
+import eventDemo.contexts.auth.infrastructure.hashPassword
+import io.ktor.resources.Resource
+import io.ktor.server.auth.authenticate
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+import io.ktor.server.routing.post
+import kotlinx.serialization.Serializable
+
+@Serializable
+@Resource("/users")
+class Users {
+ @Serializable
+ @Resource("/create")
+ class Create(
+ val username: String,
+ val password: String,
+ )
+}
+
+/**
+ * API routes to show all games.
+ */
+fun Route.createUserRoute(userRepository: UserRepository) {
+ authenticate {
+ // Create a new User, and return there ID
+ post {
+ val passwordHash = hashPassword(it.password)
+ val user = User.createNewUser(it.username, passwordHash.result)
+ userRepository.save(user)
+ call.respond(
+ object {
+ val id = user.id.toString()
+ },
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt b/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt
new file mode 100644
index 0000000..2c205ff
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/channels/GameChannelsSubscriber.kt
@@ -0,0 +1,37 @@
+package eventDemo.contexts.game.application.channels
+
+import eventDemo.contexts.game.application.command.models.GameCommand
+import eventDemo.contexts.game.application.notification.CommandSubscriber
+import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
+import eventDemo.contexts.game.application.notification.models.Notification
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.sharedKernel.UserId
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+
+class GameChannelsSubscriber(
+ private val eventToNotificationSubscriber: EventToNotificationSubscriber,
+ private val commandSubscriber: CommandSubscriber,
+) {
+ @DelicateCoroutinesApi
+ fun subscribePlayerToGameChannels(
+ gameId: GameId,
+ userId: UserId,
+ incomingCommandChannel: ReceiveChannel,
+ sendNotificationChannel: SendChannel,
+ ) {
+ val sub =
+ eventToNotificationSubscriber.subscribeToEventsAndSendNotification(
+ gameId = gameId,
+ currentUserId = userId,
+ outgoingFrameChannel = sendNotificationChannel,
+ )
+
+ commandSubscriber
+ .subscribe(
+ currentUserId = userId,
+ incomingFrameChannel = incomingCommandChannel,
+ ).invokeOnCompletion { sub.close() }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/domain/command/CommandException.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt
similarity index 56%
rename from src/main/kotlin/eventDemo/domain/command/CommandException.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt
index 187d59f..bd8feea 100644
--- a/src/main/kotlin/eventDemo/domain/command/CommandException.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/CommandException.kt
@@ -1,4 +1,4 @@
-package eventDemo.domain.command
+package eventDemo.contexts.game.application.command.handlers
class CommandException(
override val message: String,
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt
new file mode 100644
index 0000000..2fc3068
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameCommandHandlerDispatcher.kt
@@ -0,0 +1,31 @@
+package eventDemo.contexts.game.application.command.handlers
+
+import eventDemo.contexts.game.application.command.models.GameCommand
+import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
+import eventDemo.contexts.game.application.command.models.PlayCardCommand
+import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
+import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand
+import eventDemo.contexts.game.domain.game.GameId
+import java.util.Collections
+
+class GameCommandHandlerDispatcher(
+ private val playCardHandler: PlayCardHandler,
+ private val readyToPlayHandler: ReadyToPlayHandler,
+ private val joinTheGameHandler: JoinTheGameHandler,
+ private val takeCartFromDrawPileHandler: TakeCartFromDrawPileHandler,
+) {
+ companion object {
+ val lock: MutableMap = Collections.synchronizedMap(mutableMapOf())
+ }
+
+ fun dispatch(command: GameCommand) {
+ synchronized(lock.getOrPut(command.payload.aggregateId) { command.payload.aggregateId.toString() }) {
+ when (command) {
+ is JoinTheGameCommand -> joinTheGameHandler.handle(command)
+ is ReadyToPlayCommand -> readyToPlayHandler.handle(command)
+ is PlayCardCommand -> playCardHandler.handle(command)
+ is TakeCartFromDrawPileCommand -> takeCartFromDrawPileHandler.handle(command)
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt
new file mode 100644
index 0000000..2de6277
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/GameEventManager.kt
@@ -0,0 +1,45 @@
+package eventDemo.contexts.game.application.command.handlers
+
+import eventDemo.contexts.game.application.command.models.GameCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.gameState.Game
+import eventDemo.libs.command.Command
+import kotlin.reflect.KClass
+
+sealed interface CommandHandler {
+ fun handle(command: C)
+}
+
+abstract class GameEventManager(
+ private val gameRepository: GameRepository,
+ private val gameEventBus: GameEventBus,
+) {
+ fun GameCommand.getGame(): Game =
+ gameRepository.get(payload.aggregateId) ?: error("Game not found")
+
+ fun GameEvent.getGame(): Game =
+ gameRepository.get(aggregateId) ?: error("Game not found")
+
+ protected fun Game.saveEvents(): Game {
+ gameRepository.save(this)
+ return this
+ }
+
+ protected fun Game.publishEvents(): Game {
+ gameEventBus.publish(recordedEvents)
+ return this
+ }
+
+ protected fun Game.isStatusOrFail(
+ kClass: KClass,
+ message: String,
+ ): G {
+ if (kClass.isInstance(this)) {
+ return this as G
+ } else {
+ throw CommandException(message)
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt
new file mode 100644
index 0000000..4312c13
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/JoinTheGameHandler.kt
@@ -0,0 +1,26 @@
+package eventDemo.contexts.game.application.command.handlers
+
+import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.game.gameState.GameCreated
+
+/**
+ * A command to perform an action to play a new card
+ */
+class JoinTheGameHandler(
+ gameRepository: GameRepository,
+ gameEventBus: GameEventBus,
+) : GameEventManager(gameRepository, gameEventBus),
+ CommandHandler {
+ override fun handle(command: JoinTheGameCommand) {
+ command
+ .getGame()
+ .isStatusOrFail(GameCreated::class, "The game is started")
+ .userJoinTheGame(
+ userId = command.userId,
+ name = "Name${CharArray(6) { ('A'..'Z').random() }.concatToString()}",
+ ).saveEvents()
+ .publishEvents()
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt
new file mode 100644
index 0000000..c047722
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/PlayCardHandler.kt
@@ -0,0 +1,27 @@
+package eventDemo.contexts.game.application.command.handlers
+
+import eventDemo.contexts.game.application.command.models.PlayCardCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.game.gameState.GameStarted
+
+/**
+ * A command to perform an action to play a new card
+ */
+class PlayCardHandler(
+ gameRepository: GameRepository,
+ gameEventBus: GameEventBus,
+) : GameEventManager(gameRepository, gameEventBus),
+ CommandHandler {
+ override fun handle(command: PlayCardCommand) {
+ command
+ .getGame()
+ .isStatusOrFail(GameStarted::class, "The game is not started")
+ .playTheCard(
+ card = command.payload.card,
+ playerId = command.payload.playerId,
+ chosenColor = command.payload.chosenColor,
+ ).saveEvents()
+ .publishEvents()
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt
new file mode 100644
index 0000000..28c21e8
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/ReadyToPlayHandler.kt
@@ -0,0 +1,24 @@
+package eventDemo.contexts.game.application.command.handlers
+
+import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.game.gameState.GameCreated
+
+/**
+ * A command to set as ready to play
+ */
+class ReadyToPlayHandler(
+ gameRepository: GameRepository,
+ gameEventBus: GameEventBus,
+) : GameEventManager(gameRepository, gameEventBus),
+ CommandHandler {
+ override fun handle(command: ReadyToPlayCommand) {
+ command
+ .getGame()
+ .isStatusOrFail(GameCreated::class, "The game is started")
+ .setReadyPlayer(command.payload.playerId)
+ .saveEvents()
+ .publishEvents()
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt
new file mode 100644
index 0000000..2b7f92f
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/handlers/TakeCartFromDrawPileHandler.kt
@@ -0,0 +1,26 @@
+package eventDemo.contexts.game.application.command.handlers
+
+import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.game.gameState.GameStarted
+
+/**
+ * A command to draw card on draw pile.
+ *
+ * Is can be triggered when you cannot play any card in your hand.
+ */
+class TakeCartFromDrawPileHandler(
+ gameRepository: GameRepository,
+ gameEventBus: GameEventBus,
+) : GameEventManager(gameRepository, gameEventBus),
+ CommandHandler {
+ override fun handle(command: TakeCartFromDrawPileCommand) {
+ command
+ .getGame()
+ .isStatusOrFail(GameStarted::class, "The game is not started")
+ .playerTakeCartFromDrawPile(command.payload.playerId, 1)
+ .saveEvents()
+ .publishEvents()
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt
new file mode 100644
index 0000000..3b7ed9b
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/GameCommand.kt
@@ -0,0 +1,19 @@
+package eventDemo.contexts.game.application.command.models
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.libs.command.Command
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface GameCommand : Command {
+ val userId: UserId
+ val payload: Payload
+
+ @Serializable
+ sealed interface Payload {
+ @Serializable(with = GameIdSerializer::class)
+ val aggregateId: GameId
+ }
+}
diff --git a/src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt
similarity index 50%
rename from src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt
index b70d8a6..fe94917 100644
--- a/src/main/kotlin/eventDemo/domain/command/command/ICantPlayCommand.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/JoinTheGameCommand.kt
@@ -1,22 +1,24 @@
-package eventDemo.domain.command.command
+package eventDemo.contexts.game.application.command.models
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.libs.command.CommandId
+import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
-data class ICantPlayCommand(
+data class JoinTheGameCommand(
+ override val userId: UserId,
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
+ @Serializable(with = GameIdSerializer::class)
override val aggregateId: GameId,
- override val player: Player,
) : GameCommand.Payload
}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt
new file mode 100644
index 0000000..4fc874b
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/PlayCardCommand.kt
@@ -0,0 +1,31 @@
+package eventDemo.contexts.game.application.command.models
+
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.command.CommandId
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.Serializable
+
+/**
+ * A command to perform an action to play a new card
+ */
+@Serializable
+data class PlayCardCommand(
+ override val userId: UserId,
+ override val payload: Payload,
+) : GameCommand {
+ override val id: CommandId = CommandId()
+
+ @Serializable
+ data class Payload(
+ @Serializable(with = GameIdSerializer::class)
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ val playerId: Player.PlayerId,
+ val card: Card,
+ val chosenColor: Card.Color?,
+ ) : GameCommand.Payload
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt
new file mode 100644
index 0000000..143bda9
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/ReadyToPlayCommand.kt
@@ -0,0 +1,28 @@
+package eventDemo.contexts.game.application.command.models
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.command.CommandId
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.Serializable
+
+/**
+ * A command to set as ready to play
+ */
+@Serializable
+data class ReadyToPlayCommand(
+ override val userId: UserId,
+ override val payload: Payload,
+) : GameCommand {
+ override val id: CommandId = CommandId()
+
+ @Serializable
+ data class Payload(
+ @Serializable(with = GameIdSerializer::class)
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ val playerId: Player.PlayerId,
+ ) : GameCommand.Payload
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt b/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt
new file mode 100644
index 0000000..ed5b3a5
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/command/models/TakeCartFromDrawPileCommand.kt
@@ -0,0 +1,28 @@
+package eventDemo.contexts.game.application.command.models
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.command.CommandId
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.Serializable
+
+/**
+ * A command to perform an action to play a new card
+ */
+@Serializable
+data class TakeCartFromDrawPileCommand(
+ override val userId: UserId,
+ override val payload: Payload,
+) : GameCommand {
+ override val id: CommandId = CommandId()
+
+ @Serializable
+ data class Payload(
+ @Serializable(with = GameIdSerializer::class)
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ val playerId: Player.PlayerId,
+ ) : GameCommand.Payload
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt
new file mode 100644
index 0000000..61d8fb0
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameEventStoreRepository.kt
@@ -0,0 +1,24 @@
+package eventDemo.contexts.game.application.eventStores
+
+import eventDemo.contexts.game.application.ports.GameEventStore
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.gameState.Game
+
+class GameEventStoreRepository(
+ val eventStore: GameEventStore,
+) : GameRepository {
+ override fun get(id: GameId): Game? {
+ val events =
+ eventStore
+ .getStream(id)
+ .readAll()
+ if (events.isEmpty()) {
+ return null
+ }
+ return events.let { Game.loadFromHistory(it) }
+ }
+
+ override fun save(game: Game) {
+ eventStore.publish(game.recordedEvents)
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt
new file mode 100644
index 0000000..f6270aa
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/eventStores/GameRepository.kt
@@ -0,0 +1,18 @@
+package eventDemo.contexts.game.application.eventStores
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.gameState.Game
+import eventDemo.contexts.game.domain.game.gameState.GameCreated
+import eventDemo.contexts.game.domain.game.gameState.GameInit
+
+interface GameRepository {
+ fun get(id: GameId): Game?
+
+ fun save(game: Game)
+
+ fun getOrCreate(gameId: GameId): Game =
+ get(gameId) ?: create(gameId)
+
+ fun create(gameId: GameId = GameId()): GameCreated =
+ GameInit.createNewGame(gameId).also { save(it) }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt
new file mode 100644
index 0000000..656ea52
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContext.kt
@@ -0,0 +1,29 @@
+package eventDemo.contexts.game.application.logging
+
+import io.github.oshai.kotlinlogging.withLoggingContext
+
+inline fun withLoggingContext(
+ vararg pair: Pair,
+ body: () -> T,
+): T =
+ withLoggingContext(
+ *pair
+ .map {
+ it.first.name to it.second.toString()
+ }.toTypedArray(),
+ restorePrevious = true,
+ body = body,
+ )
+
+// inline fun withLoggingContext(
+// vararg pair: Pair,
+// body: () -> Unit,
+// ) =
+// withLoggingContext(
+// *pair
+// .map {
+// it.first.name to it.second.toString()
+// }.toTypedArray(),
+// restorePrevious = true,
+// body = body,
+// )
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt
new file mode 100644
index 0000000..5a76672
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/logging/LoggingContextKeys.kt
@@ -0,0 +1,9 @@
+package eventDemo.contexts.game.application.logging
+
+enum class LoggingContextKeys {
+ CurrentUserId,
+ Notification,
+ Game,
+ Event,
+ Command,
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt
new file mode 100644
index 0000000..09a3b9c
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotification.kt
@@ -0,0 +1,122 @@
+package eventDemo.contexts.game.application.notification
+
+import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification
+import eventDemo.contexts.game.application.notification.models.Notification
+import eventDemo.contexts.game.application.notification.models.PilesShuffledNotification
+import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification
+import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification
+import eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification
+import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification
+import eventDemo.contexts.game.application.notification.models.PlayerWinNotification
+import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification
+import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification
+import eventDemo.contexts.game.application.notification.models.YourNewCardNotification
+import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
+import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
+import eventDemo.contexts.game.domain.events.GameCreatedEvent
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.events.GameStartedEvent
+import eventDemo.contexts.game.domain.events.NewPlayerEvent
+import eventDemo.contexts.game.domain.events.PlayerActionEvent
+import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
+import eventDemo.contexts.game.domain.events.PlayerReadyEvent
+import eventDemo.contexts.game.domain.events.PlayerWinEvent
+import eventDemo.contexts.game.domain.game.gameState.Game
+import eventDemo.contexts.game.domain.game.gameState.GameStarted
+import eventDemo.sharedKernel.UserId
+
+fun GameEvent.toNotification(
+ game: Game,
+ currentUserId: UserId,
+): Iterable =
+ Iterable {
+ iterator {
+ val currentPlayerId = game.players.get(currentUserId).id
+
+ context(iterator: SequenceScope)
+ suspend fun Notification.send() {
+ iterator.yield(this)
+ }
+
+ fun PlayerActionEvent.isFromCurrentUser(): Boolean =
+ currentPlayerId != playerId
+
+ when (this@toNotification) {
+ is GameCreatedEvent -> {
+ // Nothing to send
+ }
+
+ is DrawFilledWithDiscardEvent -> {
+ PilesShuffledNotification().send()
+ }
+
+ is NewPlayerEvent -> {
+ if (this@toNotification.isFromCurrentUser()) {
+ PlayerAsJoinTheGameNotification(
+ player = this@toNotification.player,
+ ).send()
+ } else {
+ WelcomeToTheGameNotification(
+ players = game.players,
+ ).send()
+ }
+ }
+
+ is CardIsPlayedEvent -> {
+ PlayerAsPlayACardNotification(
+ playerId = this@toNotification.playerId,
+ card = this@toNotification.card,
+ ).send()
+
+ if (game is GameStarted) {
+ ItsTheTurnOfNotification(
+ player = game.nextPlayer,
+ ).send()
+ }
+ }
+
+ is GameStartedEvent -> {
+ TheGameWasStartedNotification(
+ hand =
+ game.players
+ .get(currentUserId)
+ .hand.cards,
+ ).send()
+
+ if (game is GameStarted) {
+ ItsTheTurnOfNotification(player = game.nextPlayer)
+ }
+ }
+
+ is PlayerHaveDrawCardEvent -> {
+ if (this@toNotification.isFromCurrentUser()) {
+ YourNewCardNotification(
+ cards = this@toNotification.takenCards,
+ ).send()
+ } else {
+ PlayerHavePassNotification(
+ playerId = this@toNotification.playerId,
+ ).send()
+ }
+
+ if (game is GameStarted) {
+ ItsTheTurnOfNotification(player = game.nextPlayer)
+ }
+ }
+
+ is PlayerReadyEvent -> {
+ if (this@toNotification.isFromCurrentUser()) {
+ PlayerWasReadyNotification(
+ playerId = this@toNotification.playerId,
+ )
+ }
+ }
+
+ is PlayerWinEvent -> {
+ PlayerWinNotification(
+ playerId = this@toNotification.playerId,
+ )
+ }
+ }
+ }
+ }
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt
new file mode 100644
index 0000000..524a455
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/EventToNotificationSubscriber.kt
@@ -0,0 +1,72 @@
+package eventDemo.contexts.game.application.notification
+
+import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
+import eventDemo.contexts.game.application.command.models.GameCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.logging.LoggingContextKeys.Command
+import eventDemo.contexts.game.application.logging.LoggingContextKeys.CurrentUserId
+import eventDemo.contexts.game.application.logging.LoggingContextKeys.Event
+import eventDemo.contexts.game.application.logging.LoggingContextKeys.Game
+import eventDemo.contexts.game.application.logging.LoggingContextKeys.Notification
+import eventDemo.contexts.game.application.logging.withLoggingContext
+import eventDemo.contexts.game.application.notification.models.Notification
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.libs.bus.Bus
+import eventDemo.libs.command.CommandUnicityChecker
+import eventDemo.sharedKernel.UserId
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.launch
+
+class EventToNotificationSubscriber(
+ private val gameEventBus: GameEventBus,
+ private val gameRepository: GameRepository,
+) {
+ fun subscribeToEventsAndSendNotification(
+ gameId: GameId,
+ currentUserId: UserId,
+ outgoingFrameChannel: SendChannel,
+ ): Bus.Subscription =
+ withLoggingContext(CurrentUserId to currentUserId) {
+ gameEventBus.subscribe { event ->
+ val game = gameRepository.get(gameId) ?: error("Game not found")
+ withLoggingContext(Event to event, Game to game) {
+ event
+ .toNotification(
+ game = game,
+ currentUserId = currentUserId,
+ ).forEach { notification ->
+ withLoggingContext(Notification to notification) {
+ outgoingFrameChannel.trySendBlocking(notification)
+ }
+ }
+ }
+ }
+ }
+}
+
+class CommandSubscriber(
+ private val gameCommandHandlerDispatcher: GameCommandHandlerDispatcher,
+) {
+ private val controller = CommandUnicityChecker()
+
+ @DelicateCoroutinesApi
+ fun subscribe(
+ currentUserId: UserId,
+ incomingFrameChannel: ReceiveChannel,
+ ): Job =
+ GlobalScope.launch {
+ for (command in incomingFrameChannel) {
+ withLoggingContext(CurrentUserId to currentUserId, Command to command) {
+ controller.runOnlyOnce(command) {
+ gameCommandHandlerDispatcher.dispatch(command)
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt
similarity index 60%
rename from src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt
index 97fb00d..76a9e78 100644
--- a/src/main/kotlin/eventDemo/domain/notification/ItsTheTurnOfNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/ItsTheTurnOfNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/domain/notification/Notification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt
similarity index 60%
rename from src/main/kotlin/eventDemo/domain/notification/Notification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt
index 258fb8d..b4a4fa0 100644
--- a/src/main/kotlin/eventDemo/domain/notification/Notification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/Notification.kt
@@ -1,6 +1,6 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt
new file mode 100644
index 0000000..90b5e83
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PilesShuffledNotification.kt
@@ -0,0 +1,11 @@
+package eventDemo.contexts.game.application.notification.models
+
+import eventDemo.libs.serializer.UUIDSerializer
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+@Serializable
+data class PilesShuffledNotification(
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+) : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt
similarity index 61%
rename from src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt
index c12291b..ade9911 100644
--- a/src/main/kotlin/eventDemo/domain/notification/PlayerAsJoinTheGameNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsJoinTheGameNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt
similarity index 50%
rename from src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt
index 20b61e1..0d6782e 100644
--- a/src/main/kotlin/eventDemo/domain/notification/PlayerAsPlayACardNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerAsPlayACardNotification.kt
@@ -1,8 +1,8 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -10,6 +10,6 @@ import java.util.UUID
data class PlayerAsPlayACardNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
- val player: Player,
+ val playerId: Player.PlayerId,
val card: Card,
) : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt
similarity index 53%
rename from src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt
index 87328e2..ac25fa1 100644
--- a/src/main/kotlin/eventDemo/domain/notification/PlayerHavePassNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerHavePassNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
- val player: Player,
+ val playerId: Player.PlayerId,
) : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt
similarity index 53%
rename from src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt
index d25b02d..9eb03e4 100644
--- a/src/main/kotlin/eventDemo/domain/notification/PlayerWasReadyNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWasReadyNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
- val player: Player,
+ val playerId: Player.PlayerId,
) : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt
similarity index 53%
rename from src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt
index bb6f86b..4b04a49 100644
--- a/src/main/kotlin/eventDemo/domain/notification/PlayerWinNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/PlayerWinNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerWinNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
- val player: Player,
+ val playerId: Player.PlayerId,
) : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt
similarity index 55%
rename from src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt
index bf1508f..34e8a9e 100644
--- a/src/main/kotlin/eventDemo/domain/notification/TheGameWasStartedNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/TheGameWasStartedNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Card
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class TheGameWasStartedNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
- val hand: List,
+ val hand: Set,
) : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt
similarity index 61%
rename from src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt
index e9b1c30..84ad0eb 100644
--- a/src/main/kotlin/eventDemo/domain/notification/WelcomeToTheGameNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/WelcomeToTheGameNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt
similarity index 54%
rename from src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt
index 328a4b0..64ad41c 100644
--- a/src/main/kotlin/eventDemo/domain/notification/YourNewCardNotification.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/notification/models/YourNewCardNotification.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.notification
+package eventDemo.contexts.game.application.notification.models
-import eventDemo.domain.entity.Card
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class YourNewCardNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
- val card: Card,
+ val cards: Set,
) : Notification
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt
new file mode 100644
index 0000000..5e2c7ff
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventBus.kt
@@ -0,0 +1,6 @@
+package eventDemo.contexts.game.application.ports
+
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.libs.bus.Bus
+
+interface GameEventBus : Bus
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt
new file mode 100644
index 0000000..4caf8ea
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameEventStore.kt
@@ -0,0 +1,7 @@
+package eventDemo.contexts.game.application.ports
+
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.libs.eventSource.eventStore.EventStore
+
+interface GameEventStore : EventStore
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt
similarity index 55%
rename from src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt
rename to src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt
index 89aef32..4873a60 100644
--- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListRepository.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameListRepository.kt
@@ -1,5 +1,7 @@
package eventDemo.domain.event.projection
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList
+
interface GameListRepository {
fun getList(): List
}
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt
new file mode 100644
index 0000000..a5d20bf
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/ports/GameProjectionBus.kt
@@ -0,0 +1,6 @@
+package eventDemo.contexts.game.application.ports
+
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
+import eventDemo.libs.bus.Bus
+
+interface GameProjectionBus : Bus
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt b/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt
new file mode 100644
index 0000000..335c443
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/projections/GameListBuilder.kt
@@ -0,0 +1,57 @@
+package eventDemo.contexts.game.application.projections
+
+import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
+import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
+import eventDemo.contexts.game.domain.events.GameCreatedEvent
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.events.GameStartedEvent
+import eventDemo.contexts.game.domain.events.NewPlayerEvent
+import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
+import eventDemo.contexts.game.domain.events.PlayerReadyEvent
+import eventDemo.contexts.game.domain.events.PlayerWinEvent
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList
+
+fun GameList.apply(event: GameEvent): GameList =
+ when (event) {
+ is GameCreatedEvent -> {
+ this
+ }
+
+ is NewPlayerEvent -> {
+ copy(
+ players = players + event.player,
+ status = GameList.Status.OPENING,
+ )
+ }
+
+ is GameStartedEvent -> {
+ copy(
+ status = GameList.Status.IS_STARTED,
+ )
+ }
+
+ is PlayerWinEvent -> {
+ copy(
+ winners = winners + players.filter { it.id == event.playerId },
+ status = GameList.Status.FINISH,
+ )
+ }
+
+ is CardIsPlayedEvent -> {
+ this
+ }
+
+ is PlayerHaveDrawCardEvent -> {
+ this
+ }
+
+ is PlayerReadyEvent -> {
+ this
+ }
+
+ is DrawFilledWithDiscardEvent -> {
+ this
+ }
+ }.copy(
+ lastEventVersion = event.version,
+ )
diff --git a/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt b/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt
new file mode 100644
index 0000000..9a3bc2d
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/application/reaction/ReactionListener.kt
@@ -0,0 +1,65 @@
+package eventDemo.contexts.game.application.reaction
+
+import eventDemo.contexts.game.application.command.handlers.GameEventManager
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.logging.LoggingContextKeys
+import eventDemo.contexts.game.application.logging.withLoggingContext
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.game.gameState.Game
+import eventDemo.contexts.game.domain.game.gameState.GameCreated
+import eventDemo.contexts.game.domain.game.gameState.GameStarted
+import io.github.oshai.kotlinlogging.KotlinLogging
+import java.util.concurrent.ConcurrentSkipListSet
+
+class ReactionListener(
+ gameRepository: GameRepository,
+ private val gameEventBus: GameEventBus,
+) : GameEventManager(gameRepository, gameEventBus) {
+ private companion object Config {
+ val registeredListeners = ConcurrentSkipListSet()
+ }
+
+ private val logger = KotlinLogging.logger { }
+
+ fun subscribeToBus() {
+ if (registeredListeners.add(gameEventBus)) {
+ gameEventBus.subscribe { event ->
+ val game = event.getGame()
+ withLoggingContext(LoggingContextKeys.Game to game) {
+ sendStartGameEvent(game)
+ sendWinnerEvent(game)
+ }
+ }
+ } else {
+ "${this::class.simpleName} is already init for this bus".let {
+ logger.error { it }
+ error(it)
+ }
+ }
+ }
+
+ private fun sendStartGameEvent(game: Game) {
+ if (game is GameCreated && game.allPlayerIsReady) {
+ game
+ .startGame()
+ .saveEvents()
+ .publishEvents()
+ }
+ }
+
+ private fun sendWinnerEvent(game: Game) {
+ if (game is GameStarted) {
+ val lastPlayerWin =
+ game
+ .players
+ .get(game.lastPlayerId)
+ .hand.size == 0
+ if (lastPlayerWin) {
+ game
+ .playerWin(game.lastPlayerId)
+ .saveEvents()
+ .publishEvents()
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt
new file mode 100644
index 0000000..323b528
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/CardIsPlayedEvent.kt
@@ -0,0 +1,31 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.Card.Color
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * An [GameEvent] to represent a played card.
+ */
+@Serializable
+data class CardIsPlayedEvent(
+ override val aggregateId: GameId,
+ val card: Card,
+ override val playerId: Player.PlayerId,
+ val chosenColor: Color? = null,
+ override val version: Int,
+) : GameEvent,
+ PlayerActionEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+
+ val theColorCard get() = if (card is Card.CardWithColor) card.color else chosenColor
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt
new file mode 100644
index 0000000..e4638a4
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/DrawFilledWithDiscardEvent.kt
@@ -0,0 +1,26 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.DiscardPile
+import eventDemo.contexts.game.domain.game.DrawPile
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * When the Pile are shuffled after the draw pille was empty
+ */
+@Serializable
+class DrawFilledWithDiscardEvent(
+ override val aggregateId: GameId,
+ val newDrawPile: DrawPile,
+ val newDiscardPile: DiscardPile,
+ override val version: Int,
+) : GameEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt
new file mode 100644
index 0000000..f3f26bf
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameCreatedEvent.kt
@@ -0,0 +1,22 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * This [GameEvent] is sent when all players are ready.
+ */
+@Serializable
+data class GameCreatedEvent(
+ override val aggregateId: GameId,
+ override val version: Int,
+) : GameEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt
new file mode 100644
index 0000000..d97881a
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameEvent.kt
@@ -0,0 +1,21 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.libs.eventSource.Event
+import eventDemo.libs.eventSource.EventId
+import kotlinx.serialization.Serializable
+
+/**
+ * An [Event] of a Game.
+ */
+@Serializable
+sealed interface GameEvent : Event {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId
+
+ @Serializable(with = GameIdSerializer::class)
+ override val aggregateId: GameId
+ override val version: Int
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt
new file mode 100644
index 0000000..141c665
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/GameStartedEvent.kt
@@ -0,0 +1,36 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.DiscardPile
+import eventDemo.contexts.game.domain.game.DrawPile
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * This [GameEvent] is sent when all players are ready.
+ */
+@Serializable
+data class GameStartedEvent(
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ val firstPlayer: Player.PlayerId,
+ val drawPile: DrawPile,
+ val discardPile: DiscardPile,
+ override val version: Int,
+) : GameEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
+
+private var isDisabled = false
+
+internal fun disableShuffleDeck() {
+ isDisabled = true
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt
new file mode 100644
index 0000000..ad39c41
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/NewPlayerEvent.kt
@@ -0,0 +1,27 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * An [GameEvent] to represent a new player joining the game.
+ */
+@Serializable
+data class NewPlayerEvent(
+ override val aggregateId: GameId,
+ val player: Player,
+ override val version: Int,
+) : GameEvent,
+ PlayerActionEvent {
+ override val playerId: Player.PlayerId get() = player.id
+
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt
new file mode 100644
index 0000000..9068b3f
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerActionEvent.kt
@@ -0,0 +1,9 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.Player
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface PlayerActionEvent : GameEvent {
+ val playerId: Player.PlayerId
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt
new file mode 100644
index 0000000..df769ab
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerHaveDrawCardEvent.kt
@@ -0,0 +1,29 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * This [GameEvent] is sent when a player can play.
+ */
+@Serializable
+data class PlayerHaveDrawCardEvent(
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ override val playerId: Player.PlayerId,
+ val takenCards: Set,
+ override val version: Int,
+) : GameEvent,
+ PlayerActionEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt
new file mode 100644
index 0000000..45f9915
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerReadyEvent.kt
@@ -0,0 +1,27 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * This [GameEvent] is sent when a player is ready.
+ */
+@Serializable
+data class PlayerReadyEvent(
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ override val playerId: Player.PlayerId,
+ override val version: Int,
+) : GameEvent,
+ PlayerActionEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt
new file mode 100644
index 0000000..4d9f3fd
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/events/PlayerWinEvent.kt
@@ -0,0 +1,27 @@
+package eventDemo.contexts.game.domain.events
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
+import eventDemo.libs.eventSource.EventId
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * This [GameEvent] is sent when a player is ready.
+ */
+@Serializable
+data class PlayerWinEvent(
+ override val aggregateId: GameId,
+ @Serializable(with = PlayerIdSerializer::class)
+ override val playerId: Player.PlayerId,
+ override val version: Int,
+) : GameEvent,
+ PlayerActionEvent {
+ @Serializable(with = EventIdSerializer::class)
+ override val eventId: EventId = EventId(UUID.randomUUID())
+ override val createdAt: Instant = Clock.System.now()
+}
diff --git a/src/main/kotlin/eventDemo/domain/entity/Card.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt
similarity index 73%
rename from src/main/kotlin/eventDemo/domain/entity/Card.kt
rename to src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt
index b6048b6..4cd3bf1 100644
--- a/src/main/kotlin/eventDemo/domain/entity/Card.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/Card.kt
@@ -1,6 +1,6 @@
-package eventDemo.domain.entity
+package eventDemo.contexts.game.domain.game
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -23,7 +23,7 @@ sealed interface Card {
Green,
}
- sealed interface ColorCard : Card {
+ sealed interface CardWithColor : Card {
val color: Color
}
@@ -38,11 +38,14 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Card,
- ColorCard {
+ CardWithColor {
init {
if (number > 9) error("Card number cannot be greater of 9")
if (number < 0) error("Card number cannot be lower of 0")
}
+
+ override fun toString(): String =
+ "Numeric Card $number $color"
}
sealed interface Special : Card
@@ -57,7 +60,10 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
- ColorCard
+ CardWithColor {
+ override fun toString(): String =
+ "Revert Card $color"
+ }
sealed interface PassTurnCard : Card
@@ -71,8 +77,11 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
- ColorCard,
- PassTurnCard
+ CardWithColor,
+ PassTurnCard {
+ override fun toString(): String =
+ "Pass Card $color"
+ }
/**
* A play card to force the next player to take 2 card and pass the turn.
@@ -84,10 +93,13 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
- ColorCard,
- PassTurnCard
+ CardWithColor,
+ PassTurnCard {
+ override fun toString(): String =
+ "Plus2 Card $color"
+ }
- sealed interface AllColorCard : Card
+ sealed interface CardWith4Color : Card
/**
* A play card to force the next player to take 4 card and pass the turn.
@@ -98,8 +110,11 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
- AllColorCard,
- PassTurnCard
+ CardWith4Color,
+ PassTurnCard {
+ override fun toString(): String =
+ "Plus4 Card"
+ }
/**
* A play card to change the color.
@@ -110,5 +125,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
- AllColorCard
+ CardWith4Color {
+ override fun toString(): String =
+ "Change color Card"
+ }
}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt
new file mode 100644
index 0000000..82db786
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/DiscardPile.kt
@@ -0,0 +1,18 @@
+package eventDemo.contexts.game.domain.game
+
+import kotlinx.serialization.Serializable
+
+@JvmInline
+@Serializable
+value class DiscardPile(
+ val cards: Set = emptySet(),
+) {
+ fun withNewCard(card: Card): DiscardPile =
+ DiscardPile(cards + card)
+
+ val topCard: Card? get() = cards.lastOrNull()
+
+ val topCardColor: Card.Color? get() = topCard?.let { if (it is Card.CardWithColor) it.color else null }
+
+ val size: Int get() = cards.size
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt
new file mode 100644
index 0000000..9eadf36
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/DrawPile.kt
@@ -0,0 +1,31 @@
+package eventDemo.contexts.game.domain.game
+
+import kotlinx.serialization.Serializable
+
+@JvmInline
+@Serializable
+value class DrawPile(
+ val cards: Set = emptySet(),
+) {
+ fun take(number: Int): Pair> =
+ cards.drop(number).toDrawPile() to cards.take(number).toSet()
+
+ val remainingCards
+ get() = cards.size
+
+ fun shuffled(): DrawPile =
+ cards.shuffled().toDrawPile()
+
+ val firstCard get() = cards.first()
+
+ fun generateValidDrawPile(): DrawPile =
+ if (cards.first() is Card.CardWith4Color) {
+ DrawPile(setOf(cards.first()) + cards.drop(1))
+ .generateValidDrawPile()
+ } else {
+ this
+ }
+}
+
+private fun List.toDrawPile(): DrawPile =
+ DrawPile(this.toSet())
diff --git a/src/main/kotlin/eventDemo/domain/entity/GameId.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt
similarity index 56%
rename from src/main/kotlin/eventDemo/domain/entity/GameId.kt
rename to src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt
index 4b364dc..63401b9 100644
--- a/src/main/kotlin/eventDemo/domain/entity/GameId.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/GameId.kt
@@ -1,7 +1,7 @@
-package eventDemo.domain.entity
+package eventDemo.contexts.game.domain.game
-import eventDemo.configuration.serializer.GameIdSerializer
-import eventDemo.libs.event.AggregateId
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,8 +9,9 @@ import java.util.UUID
* An [AggregateId] for a game.
*/
@JvmInline
-@Serializable(with = GameIdSerializer::class)
+@Serializable
value class GameId(
+ @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
override fun toString(): String =
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt
new file mode 100644
index 0000000..c071206
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/Player.kt
@@ -0,0 +1,61 @@
+package eventDemo.contexts.game.domain.game
+
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.helpers.withReplacedValue
+import eventDemo.libs.serializer.UUIDSerializer
+import eventDemo.sharedKernel.UserId
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+@Serializable
+data class Player(
+ val name: String,
+ val userId: UserId,
+ val hand: PlayerHand = PlayerHand(),
+ val id: PlayerId = PlayerId(UUID.randomUUID()),
+) {
+ @JvmInline
+ @Serializable
+ value class PlayerId(
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+ ) : AggregateId {
+ override fun toString(): String =
+ id.toString()
+ }
+}
+
+class PlayerList(
+ val players: Set = emptySet(),
+) : Set by players {
+ fun get(id: Player.PlayerId): Player =
+ find { it.id == id } ?: error("no player with id $id")
+
+ fun get(id: UserId): Player =
+ find { it.userId == id } ?: error("no player with userId $id")
+
+ fun withNewCardOnPlayerHand(
+ playerId: Player.PlayerId,
+ cards: Set,
+ ): PlayerList =
+ players
+ .withReplacedValue(get(playerId)) {
+ it.copy(
+ hand = it.hand.withNewCards(cards),
+ )
+ }.let { PlayerList(it) }
+
+ fun withDropCardOnPlayerHand(
+ playerId: Player.PlayerId,
+ card: Card,
+ ): PlayerList =
+ players
+ .withReplacedValue(get(playerId)) {
+ it.copy(
+ hand = it.hand.withoutTheCards(setOf(card)),
+ )
+ }.let { PlayerList(it) }
+
+ operator fun plus(player: Player): PlayerList =
+ PlayerList(players + player)
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt
new file mode 100644
index 0000000..7ddf68b
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/PlayerHand.kt
@@ -0,0 +1,18 @@
+package eventDemo.contexts.game.domain.game
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+@JvmInline
+value class PlayerHand(
+ val cards: Set = emptySet(),
+) {
+ fun withNewCards(newCards: Set): PlayerHand =
+ PlayerHand(cards + newCards)
+
+ fun withoutTheCards(newCards: Set): PlayerHand =
+ PlayerHand(cards - newCards)
+
+ val size: Int get() =
+ cards.size
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt
new file mode 100644
index 0000000..d612630
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/errors/GameException.kt
@@ -0,0 +1,68 @@
+package eventDemo.contexts.game.domain.game.errors
+
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.domain.game.PlayerList
+import eventDemo.contexts.game.domain.game.gameState.Deck
+import eventDemo.contexts.game.domain.game.gameState.Game
+
+abstract class GameException(
+ message: String,
+) : Exception(message)
+
+abstract class IllegalActionException(
+ message: String,
+) : GameException(message)
+
+class ItsNotTheTurnException(
+ val playerId: Player.PlayerId,
+) : IllegalActionException("It is not the turn of the player") {
+ constructor(playerId: Player.PlayerId, event: GameEvent) : this(playerId)
+}
+
+class TheCardIsAColorCardException(
+ val playerId: Player.PlayerId,
+) : IllegalActionException("The card is a color card")
+
+class TheCardHasNoColorException(
+ val playerId: Player.PlayerId,
+) : IllegalActionException("The card has no color, you must chose a color")
+
+class ThePlayerHasRemainingCardsException(
+ val player: Player,
+) : IllegalActionException("The player has remaining cards")
+
+class ThePlayerHasAlreadyWinException(
+ val playerId: Player.PlayerId,
+) : IllegalActionException("The player has already win")
+
+class ThePlayerIsNotInTheGameException(
+ val playerId: Player.PlayerId,
+) : IllegalActionException("The player is not in the game")
+
+class ThePlayerMustPlayACardException(
+ val playerId: Player.PlayerId,
+ val playableCards: Set,
+) : IllegalActionException("The player must be play a card")
+
+class NeedMorePlayersToStartGameException(
+ val players: PlayerList,
+) : IllegalActionException("You cannot start a game with less than 2 players!")
+
+class AllPlayerNotReadyException(
+ val players: PlayerList,
+) : IllegalActionException("All players not ready!")
+
+class DeckMissingCardsException(
+ val players: PlayerList,
+ deck: Deck,
+) : IllegalActionException("The deck missing cards")
+
+class InconsistentGameException(
+ val game: Game,
+) : GameException("Inconsistent game state")
+
+class InconsistentEventVersionException(
+ val game: Set,
+) : GameException("Inconsistent event version")
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt
new file mode 100644
index 0000000..fadc9dc
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/Game.kt
@@ -0,0 +1,88 @@
+package eventDemo.contexts.game.domain.game.gameState
+
+import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
+import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
+import eventDemo.contexts.game.domain.events.GameCreatedEvent
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.events.GameStartedEvent
+import eventDemo.contexts.game.domain.events.NewPlayerEvent
+import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
+import eventDemo.contexts.game.domain.events.PlayerReadyEvent
+import eventDemo.contexts.game.domain.events.PlayerWinEvent
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.PlayerList
+import eventDemo.contexts.game.domain.game.errors.GameException
+import eventDemo.contexts.game.domain.game.errors.InconsistentEventVersionException
+
+sealed interface Game {
+ val aggregateId: GameId
+ val players: PlayerList
+
+ /**
+ * On each modification, an event is put in their
+ */
+ val recordedEvents: Set
+ val version: Int
+
+ enum class Direction {
+ CLOCKWISE,
+ COUNTER_CLOCKWISE,
+ ;
+
+ fun revert(): Direction =
+ if (this === CLOCKWISE) {
+ COUNTER_CLOCKWISE
+ } else {
+ CLOCKWISE
+ }
+ }
+
+ companion object {
+ fun loadFromHistory(events: Set): Game =
+ events.fold(GameInit(events.first().aggregateId)) { game: Game, event ->
+ game.run {
+ when (event) {
+ is GameCreatedEvent if this is GameInit -> applyEvent(event)
+ is GameCreatedEvent -> error("Game is already created")
+ is NewPlayerEvent if this is GameCreated -> applyEvent(event)
+ is NewPlayerEvent -> error("Game is already stared")
+ is PlayerReadyEvent if this is GameCreated -> applyEvent(event)
+ is PlayerReadyEvent -> error("Game is already stared")
+ is GameStartedEvent if this is GameCreated -> applyEvent(event)
+ is GameStartedEvent -> error("Game is already started")
+ is CardIsPlayedEvent if this is GameStarted -> applyEvent(event)
+ is CardIsPlayedEvent -> error("Game is end")
+ is PlayerHaveDrawCardEvent if this is GameStarted -> applyEvent(event)
+ is PlayerHaveDrawCardEvent -> error("Game is end")
+ is PlayerWinEvent if this is GameStarted -> applyEvent(event)
+ is PlayerWinEvent -> error("Game is end")
+ is DrawFilledWithDiscardEvent if this is GameStarted -> applyEvent(event)
+ is DrawFilledWithDiscardEvent -> error("Game is end")
+ }
+ }
+ }
+ }
+}
+
+internal fun T.checkState(
+ block: (T) -> Boolean,
+ exception: (T) -> GameException,
+): T {
+ if (!block(this)) throw exception(this)
+ return this
+}
+
+/**
+ * recordedEvents versions must be ordered and incremental.
+ */
+internal fun Game.checkRecorderEventsConsistency() {
+ recordedEvents
+ .also { if (it.size != (it.lastOrNull()?.version ?: 0)) throw InconsistentEventVersionException(recordedEvents) }
+ .mapIndexed { index, event ->
+ (index + 1) == event.version
+ }.run {
+ if (any { !it }) {
+ throw InconsistentEventVersionException(recordedEvents)
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt
new file mode 100644
index 0000000..95c8b94
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameCreated.kt
@@ -0,0 +1,140 @@
+package eventDemo.contexts.game.domain.game.gameState
+
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.events.GameStartedEvent
+import eventDemo.contexts.game.domain.events.NewPlayerEvent
+import eventDemo.contexts.game.domain.events.PlayerReadyEvent
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.DiscardPile
+import eventDemo.contexts.game.domain.game.DrawPile
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.domain.game.PlayerList
+import eventDemo.contexts.game.domain.game.errors.AllPlayerNotReadyException
+import eventDemo.contexts.game.domain.game.errors.DeckMissingCardsException
+import eventDemo.contexts.game.domain.game.errors.NeedMorePlayersToStartGameException
+import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException
+import eventDemo.sharedKernel.UserId
+import kotlin.collections.plus
+
+data class GameCreated(
+ override val aggregateId: GameId,
+ override val players: PlayerList = PlayerList(),
+ val playersStatus: Map = emptyMap(),
+ override val recordedEvents: Set,
+ override val version: Int,
+) : Game {
+ init {
+ checkRecorderEventsConsistency()
+ }
+
+ val allPlayerIsReady: Boolean
+ get() {
+ return playersStatus.values.all { it == PlayerStatus.Ready }
+ }
+
+ fun startGame(deck: Deck = newDeck().shuffleDeck()): GameStarted {
+ val (drawPile, discardPile) = initPiles(deck)
+
+ return GameStartedEvent(
+ aggregateId = aggregateId,
+ firstPlayer = players.random().id,
+ version = version + 1,
+ drawPile = drawPile,
+ discardPile = discardPile,
+ ).checkState(
+ { players.size > 1 },
+ { NeedMorePlayersToStartGameException(players) },
+ ).checkState(
+ { allPlayerIsReady },
+ { AllPlayerNotReadyException(players) },
+ ).checkState(
+ { deck.size == 108 },
+ { DeckMissingCardsException(players, deck) },
+ ).run(::applyEvent)
+ }
+
+ private fun initPiles(deck: Set): Pair =
+ DrawPile(deck)
+ .generateValidDrawPile()
+ .take(1)
+ .let { (draw, cards) ->
+ draw to DiscardPile(cards)
+ }
+
+ fun userJoinTheGame(
+ userId: UserId,
+ name: String,
+ ): GameCreated {
+ if (players.map { it.userId }.contains(userId)) {
+ throw IllegalStateException("User $userId already in party")
+ }
+
+ val player = Player(name, userId)
+
+ return applyEvent(
+ NewPlayerEvent(
+ aggregateId = aggregateId,
+ player = player,
+ version = version + 1,
+ ),
+ )
+ }
+
+ fun setReadyPlayer(playerId: Player.PlayerId): GameCreated {
+ if (!players.map { it.id }.contains(playerId)) {
+ throw ThePlayerIsNotInTheGameException(playerId)
+ }
+
+ return PlayerReadyEvent(aggregateId, playerId, version + 1)
+ .run(::applyEvent)
+ }
+
+ internal fun applyEvent(event: NewPlayerEvent): GameCreated =
+ copy(
+ players = players + (event.player),
+ playersStatus = playersStatus + (event.player.id to PlayerStatus.Waiting),
+ recordedEvents = recordedEvents + event,
+ version = version + 1,
+ )
+
+ internal fun applyEvent(event: PlayerReadyEvent): GameCreated =
+ copy(
+ playersStatus = playersStatus + (event.playerId to PlayerStatus.Ready),
+ recordedEvents = recordedEvents + event,
+ version = version + 1,
+ )
+
+ internal fun applyEvent(event: GameStartedEvent): GameStarted =
+ GameStarted(
+ aggregateId = event.aggregateId,
+ players = players,
+ lastPlayerId = event.firstPlayer,
+ drawPile = event.drawPile,
+ discardPile = event.discardPile,
+ version = event.version + 1,
+ recordedEvents = recordedEvents + event,
+ currentColor = event.discardPile.topCardColor ?: error("The discard pile was not initialized!"),
+ )
+
+ enum class PlayerStatus {
+ Ready,
+ Waiting,
+ }
+}
+
+typealias Deck = Set
+
+fun newDeck(): 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 {
+ it + (1..4).map { Card.Plus4Card() }
+ }.toSet()
+
+fun Set.shuffleDeck() =
+ shuffled().toSet()
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt
new file mode 100644
index 0000000..a73fa23
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameEnded.kt
@@ -0,0 +1,21 @@
+package eventDemo.contexts.game.domain.game.gameState
+
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.domain.game.PlayerList
+
+data class GameEnded(
+ override val aggregateId: GameId,
+ override val players: PlayerList,
+ val playerWins: Set = emptySet(),
+ override val version: Int,
+ override val recordedEvents: Set,
+) : Game {
+ init {
+ if (!players.map { it.id }.containsAll(playerWins)) {
+ throw IllegalArgumentException("Player ${players.map { it.id }} were not in players")
+ }
+ checkRecorderEventsConsistency()
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt
new file mode 100644
index 0000000..939d008
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameInit.kt
@@ -0,0 +1,28 @@
+package eventDemo.contexts.game.domain.game.gameState
+
+import eventDemo.contexts.game.domain.events.GameCreatedEvent
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.PlayerList
+
+data class GameInit(
+ override val aggregateId: GameId,
+) : Game {
+ override val players: PlayerList = PlayerList()
+ override var recordedEvents: Set = emptySet()
+
+ // 0 = no events; not persisted; not really exist.
+ override var version: Int = 0
+
+ companion object {
+ fun createNewGame(gameId: GameId = GameId()): GameCreated {
+ val event = GameCreatedEvent(gameId, 1)
+ return GameInit(event.aggregateId).run {
+ event.run(::applyEvent)
+ }
+ }
+ }
+
+ internal fun applyEvent(event: GameCreatedEvent): GameCreated =
+ GameCreated(aggregateId, recordedEvents = setOf(event), version = event.version)
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt
new file mode 100644
index 0000000..8e6fd24
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/domain/game/gameState/GameStarted.kt
@@ -0,0 +1,256 @@
+package eventDemo.contexts.game.domain.game.gameState
+
+import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
+import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.events.PlayerActionEvent
+import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
+import eventDemo.contexts.game.domain.events.PlayerWinEvent
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.Card.Color
+import eventDemo.contexts.game.domain.game.DiscardPile
+import eventDemo.contexts.game.domain.game.DrawPile
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.domain.game.PlayerList
+import eventDemo.contexts.game.domain.game.errors.InconsistentGameException
+import eventDemo.contexts.game.domain.game.errors.ItsNotTheTurnException
+import eventDemo.contexts.game.domain.game.errors.TheCardHasNoColorException
+import eventDemo.contexts.game.domain.game.errors.TheCardIsAColorCardException
+import eventDemo.contexts.game.domain.game.errors.ThePlayerHasAlreadyWinException
+import eventDemo.contexts.game.domain.game.errors.ThePlayerHasRemainingCardsException
+import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException
+import eventDemo.contexts.game.domain.game.errors.ThePlayerMustPlayACardException
+import eventDemo.contexts.game.domain.game.gameState.Game.Direction
+
+data class GameStarted(
+ override val aggregateId: GameId,
+ override val players: PlayerList,
+ val drawPile: DrawPile,
+ val discardPile: DiscardPile,
+ val lastPlayerId: Player.PlayerId,
+ val currentColor: Color,
+ val playedTurnHistory: List = emptyList(),
+ val direction: Direction = Direction.CLOCKWISE,
+ val playerWins: Set = emptySet(),
+ override val version: Int,
+ override val recordedEvents: Set,
+) : Game {
+ val playersInGame by lazy { players.filter { it.hand.cards.isNotEmpty() } }
+
+ val lastPlayedCard: Card? by lazy { discardPile.topCard }
+
+ val lastPlayed: Player by lazy { players.get(lastPlayerId) }
+
+ init {
+ checkRecorderEventsConsistency()
+ }
+
+ data class History(
+ val playerId: Player.PlayerId,
+ val event: GameEvent,
+ val direction: Direction,
+ )
+
+ val lastPlayer by lazy { players.get(lastPlayerId) }
+
+ val nextPlayer: Player by lazy {
+ val playersLastTurn = players.filter { it.hand.cards.isNotEmpty() || it == lastPlayer }
+
+ playersLastTurn
+ .indexOf(lastPlayer)
+ .let { lastPlayerIndex ->
+ if (direction == Direction.CLOCKWISE) {
+ if (lastPlayerIndex == playersLastTurn.size - 1) {
+ 0
+ } else {
+ lastPlayerIndex + 1
+ }
+ } else {
+ if (lastPlayerIndex == 0) {
+ playersLastTurn.size - 1
+ } else {
+ lastPlayerIndex - 1
+ }
+ }
+ }.let { nextPlayerIndex -> players.elementAt(nextPlayerIndex) }
+ }
+
+ fun canBePlayThisCard(card: Card): Boolean {
+ val cardOnBoard = discardPile.topCard ?: return false
+ return when (cardOnBoard) {
+ is Card.NumericCard -> {
+ when (card) {
+ is Card.CardWith4Color -> true
+ is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
+ is Card.CardWithColor -> card.color == cardOnBoard.color
+ }
+ }
+
+ is Card.ReverseCard -> {
+ when (card) {
+ is Card.ReverseCard -> true
+ is Card.CardWith4Color -> true
+ is Card.CardWithColor -> card.color == cardOnBoard.color
+ }
+ }
+
+ is Card.PassCard -> {
+ when (card) {
+ is Card.CardWith4Color -> true
+ is Card.CardWithColor -> card.color == cardOnBoard.color
+ }
+ }
+
+ is Card.ChangeColorCard -> {
+ when (card) {
+ is Card.CardWith4Color -> true
+ is Card.CardWithColor -> card.color == currentColor
+ }
+ }
+
+ is Card.Plus2Card -> {
+ when (card) {
+ is Card.Plus2Card -> true
+ ::isPlayedLastTurn -> false
+ is Card.CardWith4Color -> true
+ is Card.CardWithColor -> card.color == currentColor
+ }
+ }
+
+ is Card.Plus4Card -> {
+ when (card) {
+ is Card.Plus4Card -> true
+ ::isPlayedLastTurn -> false
+ is Card.CardWith4Color -> true
+ is Card.CardWithColor -> card.color == currentColor
+ }
+ }
+ }
+ }
+
+ fun playableCards(playerId: Player.PlayerId): Set =
+ players
+ .get(playerId)
+ .hand
+ .cards
+ .filter(::canBePlayThisCard)
+ .toSet()
+
+ fun playTheCard(
+ playerId: Player.PlayerId,
+ card: Card,
+ chosenColor: Color? = null,
+ ): GameStarted =
+ CardIsPlayedEvent(aggregateId, card, playerId, chosenColor, version + 1)
+ .checkPlayerTurn()
+ .checkState({ card is Card.CardWithColor && chosenColor != null }, { TheCardIsAColorCardException(playerId) })
+ .checkState({ card is Card.CardWith4Color && chosenColor == null }, { TheCardHasNoColorException(playerId) })
+ .run(::applyEvent)
+
+ internal fun applyEvent(event: CardIsPlayedEvent): GameStarted =
+ run {
+ val nextDirectionAfterPlay =
+ when (event.card) {
+ is Card.ReverseCard -> direction.revert()
+ else -> direction
+ }
+
+ val color =
+ when (event.card) {
+ is Card.CardWithColor -> event.card.color
+ is Card.CardWith4Color -> event.chosenColor!!
+ }
+
+ copy(
+ players = players.withDropCardOnPlayerHand(event.playerId, event.card),
+ discardPile = discardPile.withNewCard(card = event.card),
+ currentColor = color,
+ lastPlayerId = event.playerId,
+ playedTurnHistory = playedTurnHistory - History(event.playerId, event, direction),
+ direction = nextDirectionAfterPlay,
+ version = event.version,
+ recordedEvents = recordedEvents + event,
+ )
+ }
+
+ fun playerTakeCartFromDrawPile(
+ playerId: Player.PlayerId,
+ number: Int,
+ ): GameStarted {
+ val takenCards = drawPile.take(number).second
+ return PlayerHaveDrawCardEvent(aggregateId, playerId, takenCards, version + 1)
+ .checkPlayerTurn()
+ .checkState({
+ playableCards(playerId).isEmpty()
+ }, {
+ ThePlayerMustPlayACardException(playerId, playableCards(playerId))
+ })
+ .run(::applyEvent)
+ .run {
+ val missingCardsCount = number - takenCards.size
+ if (missingCardsCount > 0) {
+ fillDrawWithDiscard()
+ .playerTakeCartFromDrawPile(playerId, missingCardsCount)
+ } else {
+ this
+ }
+ }
+ }
+
+ internal fun applyEvent(event: PlayerHaveDrawCardEvent): GameStarted =
+ copy(
+ players = players.withNewCardOnPlayerHand(event.playerId, event.takenCards),
+ drawPile = drawPile.take(event.takenCards.size).first,
+ version = event.version,
+ recordedEvents = recordedEvents + event,
+ )
+
+ /**
+ * Filling the draw pile with the discard pile while excluding the top card
+ */
+ private fun fillDrawWithDiscard(): GameStarted =
+ run {
+ val topCard = discardPile.topCard ?: throw InconsistentGameException(this)
+ DrawPile(discardPile.cards - topCard).shuffled() to DiscardPile(setOf(topCard))
+ }.let { (newDrawPile, newDiscardPile) ->
+ DrawFilledWithDiscardEvent(aggregateId, newDrawPile, newDiscardPile, version + 1)
+ .run(::applyEvent)
+ }
+
+ internal fun applyEvent(event: DrawFilledWithDiscardEvent): GameStarted =
+ copy(
+ drawPile = event.newDrawPile,
+ discardPile = event.newDiscardPile,
+ version = event.version,
+ recordedEvents = recordedEvents + event,
+ )
+
+ fun playerWin(playerId: Player.PlayerId): GameStarted =
+ PlayerWinEvent(aggregateId, playerId, version + 1)
+ .checkState({
+ players
+ .get(playerId)
+ .hand.cards
+ .isEmpty()
+ }, { ThePlayerHasRemainingCardsException(players.get(playerId)) })
+ .checkState({ playerWins.contains(playerId) }, { ThePlayerHasAlreadyWinException(playerId) })
+ .checkState({ !players.map { it.id }.contains(playerId) }, { ThePlayerIsNotInTheGameException(playerId) })
+ .run(::applyEvent)
+
+ internal fun applyEvent(event: PlayerWinEvent): GameStarted =
+ copy(
+ playerWins = playerWins + event.playerId,
+ version = event.version,
+ recordedEvents = recordedEvents + event,
+ )
+
+ private fun T.checkPlayerTurn(): T =
+ checkState(
+ { nextPlayer.id == playerId },
+ { ItsNotTheTurnException(playerId, this) },
+ )
+
+ private fun isPlayedLastTurn(card: Card): Boolean =
+ (playedTurnHistory.last().event as? CardIsPlayedEvent)?.card == card
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt
new file mode 100644
index 0000000..4bb8d0b
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDIApplication.kt
@@ -0,0 +1,17 @@
+package eventDemo.contexts.game.infrastructure.configuration.injections.application
+
+import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
+import eventDemo.contexts.game.application.reaction.ReactionListener
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.bind
+
+fun Module.configureGameDIApplication() {
+ configureDICommandHandlers()
+
+ singleOf(::ReactionListener)
+ singleOf(::EventToNotificationSubscriber)
+ singleOf(::GameEventStoreRepository) bind GameRepository::class
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt
new file mode 100644
index 0000000..047b4bc
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/application/ConfigureDICommandHandlers.kt
@@ -0,0 +1,18 @@
+package eventDemo.contexts.game.infrastructure.configuration.injections.application
+
+import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler
+import eventDemo.contexts.game.application.command.handlers.PlayCardHandler
+import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler
+import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.singleOf
+
+/**
+ * Configure all actions
+ */
+fun Module.configureDICommandHandlers() {
+ singleOf(::PlayCardHandler)
+ singleOf(::ReadyToPlayHandler)
+ singleOf(::JoinTheGameHandler)
+ singleOf(::TakeCartFromDrawPileHandler)
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt
new file mode 100644
index 0000000..4b6c0a9
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/injections/infrastructure/ConfigureDIInfrastructure.kt
@@ -0,0 +1,26 @@
+package eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure
+
+import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
+import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
+import eventDemo.contexts.game.application.notification.CommandSubscriber
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.application.ports.GameEventStore
+import eventDemo.contexts.game.application.ports.GameProjectionBus
+import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ
+import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInPostgresql
+import eventDemo.contexts.game.infrastructure.persistence.projections.GameListRepositoryInRedis
+import eventDemo.contexts.game.infrastructure.persistence.projections.bus.GameProjectionBusInRabbitMQ
+import eventDemo.domain.event.projection.GameListRepository
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.bind
+
+fun Module.configureGameDIInfrastructure() {
+ singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
+ singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
+ singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class
+ singleOf(::CommandSubscriber)
+ singleOf(::GameChannelsSubscriber)
+ singleOf(::GameCommandHandlerDispatcher)
+ singleOf(::GameListRepositoryInRedis) bind GameListRepository::class
+}
diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt
similarity index 96%
rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt
index 91c3c12..7620756 100644
--- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureHttp.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureHttp.kt
@@ -1,4 +1,4 @@
-package eventDemo.configuration.ktor
+package eventDemo.contexts.game.infrastructure.configuration.ktor
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt
similarity index 55%
rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt
index 363996f..89da12e 100644
--- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureSerialization.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureSerialization.kt
@@ -1,12 +1,14 @@
-package eventDemo.configuration.ktor
+package eventDemo.contexts.game.infrastructure.configuration.ktor
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.CommandIdSerializer
-import eventDemo.configuration.serializer.GameIdSerializer
-import eventDemo.configuration.serializer.PlayerIdSerializer
-import eventDemo.configuration.serializer.UUIDSerializer
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
import eventDemo.libs.command.CommandId
+import eventDemo.libs.eventSource.EventId
+import eventDemo.libs.serializer.UUIDSerializer
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
@@ -29,6 +31,7 @@ fun defaultJsonSerializer(): Json =
SerializersModule {
contextual(UUID::class) { UUIDSerializer }
contextual(GameId::class) { GameIdSerializer }
+ contextual(EventId::class) { EventIdSerializer }
contextual(CommandId::class) { CommandIdSerializer }
contextual(Player.PlayerId::class) { PlayerIdSerializer }
}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt
new file mode 100644
index 0000000..1c7af65
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureUno.kt
@@ -0,0 +1,21 @@
+package eventDemo.contexts.game.infrastructure.configuration.ktor
+
+import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener
+import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener
+import io.ktor.server.application.Application
+import org.koin.ktor.plugin.koin
+
+fun Application.configureUno() {
+ configureSerialization()
+
+ configureWebSockets()
+ declareWebSocketsRoute()
+
+ configureHttpRouting()
+ declareHttpGameRoute()
+
+ koin().run {
+ configureProjectionListener()
+ configureReactionListener()
+ }
+}
diff --git a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt
similarity index 86%
rename from src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt
index e97f11a..422e759 100644
--- a/src/main/kotlin/eventDemo/configuration/ktor/ConfigureWebSockets.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/ConfigureWebSockets.kt
@@ -1,4 +1,4 @@
-package eventDemo.configuration.ktor
+package eventDemo.contexts.game.infrastructure.configuration.ktor
import io.ktor.server.application.Application
import io.ktor.server.application.install
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt
new file mode 100644
index 0000000..1fdcabf
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareHttpGameRoutes.kt
@@ -0,0 +1,14 @@
+package eventDemo.contexts.game.infrastructure.configuration.ktor
+
+import eventDemo.contexts.game.infrastructure.rest.gamesListRoute
+import eventDemo.contexts.game.infrastructure.rest.getFullNotificationsRoute
+import io.ktor.server.application.Application
+import io.ktor.server.routing.routing
+import org.koin.ktor.ext.get as getDi
+
+fun Application.declareHttpGameRoute() {
+ routing {
+ gamesListRoute(getDi())
+ getFullNotificationsRoute(getDi(), getDi())
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt
new file mode 100644
index 0000000..7932bc4
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/ktor/DeclareWebSocketsGameRoute.kt
@@ -0,0 +1,16 @@
+package eventDemo.contexts.game.infrastructure.configuration.ktor
+
+import eventDemo.contexts.game.infrastructure.websocket.gameWebSocket
+import io.ktor.server.application.Application
+import io.ktor.server.routing.routing
+import kotlinx.coroutines.DelicateCoroutinesApi
+import org.koin.ktor.ext.get as getDi
+
+@OptIn(DelicateCoroutinesApi::class)
+fun Application.declareWebSocketsRoute() {
+ routing {
+ gameWebSocket(
+ getDi(),
+ )
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt
new file mode 100644
index 0000000..b9b73be
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureGameListener.kt
@@ -0,0 +1,9 @@
+package eventDemo.contexts.game.infrastructure.configuration.listener
+
+import eventDemo.contexts.game.infrastructure.persistence.projections.GameListRepositoryInRedis
+import org.koin.core.Koin
+
+fun Koin.configureProjectionListener() {
+ get()
+ .subscribeToBus()
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt
new file mode 100644
index 0000000..549214d
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/configuration/listener/ConfigureReactionListener.kt
@@ -0,0 +1,9 @@
+package eventDemo.contexts.game.infrastructure.configuration.listener
+
+import eventDemo.contexts.game.application.reaction.ReactionListener
+import org.koin.core.Koin
+
+fun Koin.configureReactionListener() {
+ get()
+ .subscribeToBus()
+}
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt
similarity index 68%
rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt
index d72487d..c202cc9 100644
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInMemory.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInMemory.kt
@@ -1,7 +1,7 @@
-package eventDemo.adapter.infrastructure.event
+package eventDemo.contexts.game.infrastructure.persistence.eventBus
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.event.GameEvent
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt
similarity index 77%
rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt
index 03baba9..7c25a0a 100644
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbinMQ.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventBus/GameEventBusInRabbinMQ.kt
@@ -1,8 +1,8 @@
-package eventDemo.adapter.infrastructure.event
+package eventDemo.contexts.game.infrastructure.persistence.eventBus
import com.rabbitmq.client.ConnectionFactory
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.event.GameEvent
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt
new file mode 100644
index 0000000..ea44440
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInMemory.kt
@@ -0,0 +1,14 @@
+package eventDemo.contexts.game.infrastructure.persistence.eventStore
+
+import eventDemo.contexts.game.application.ports.GameEventStore
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.libs.eventSource.eventStore.EventStore
+import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
+
+/**
+ * A stream to publish and read the played card event.
+ */
+class GameEventStoreInMemory :
+ GameEventStore,
+ EventStore by EventStoreInMemory()
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt
new file mode 100644
index 0000000..a25e219
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/eventStore/GameEventStoreInPostgresql.kt
@@ -0,0 +1,22 @@
+package eventDemo.contexts.game.infrastructure.persistence.eventStore
+
+import eventDemo.contexts.game.application.ports.GameEventStore
+import eventDemo.contexts.game.domain.events.GameEvent
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.libs.eventSource.eventStore.EventStore
+import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
+import kotlinx.serialization.json.Json
+import javax.sql.DataSource
+
+/**
+ * A stream to publish and read the played card event.
+ */
+class GameEventStoreInPostgresql(
+ dataSource: DataSource,
+) : GameEventStore,
+ EventStore by EventStoreInPostgresql(
+ dataSource,
+ { Json.encodeToString(it) },
+ { Json.decodeFromString(it) },
+ "game.game_event_stream",
+ )
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt
similarity index 70%
rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt
index 42e3575..dcb8eec 100644
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInMemory.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInMemory.kt
@@ -1,13 +1,12 @@
-package eventDemo.adapter.infrastructure.event.projection
+package eventDemo.contexts.game.infrastructure.persistence.projections
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.projection.GameList
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.application.ports.GameProjectionBus
+import eventDemo.contexts.game.application.projections.apply
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList
import eventDemo.domain.event.projection.GameListRepository
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameState
-import eventDemo.domain.event.projection.apply
-import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
+import eventDemo.libs.eventSource.projection.ProjectionRepositoryInMemory
import io.github.oshai.kotlinlogging.withLoggingContext
/**
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInRedis.kt
similarity index 69%
rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInRedis.kt
index 51ed7e1..7e90899 100644
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameListRepositoryInRedis.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/GameListRepositoryInRedis.kt
@@ -1,13 +1,12 @@
-package eventDemo.adapter.infrastructure.event.projection
+package eventDemo.contexts.game.infrastructure.persistence.projections
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.projection.GameList
+import eventDemo.contexts.game.application.ports.GameEventBus
+import eventDemo.contexts.game.application.ports.GameProjectionBus
+import eventDemo.contexts.game.application.projections.apply
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList
import eventDemo.domain.event.projection.GameListRepository
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameState
-import eventDemo.domain.event.projection.apply
-import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
+import eventDemo.libs.eventSource.projection.ProjectionRepositoryInRedis
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.serialization.json.Json
import redis.clients.jedis.UnifiedJedis
@@ -17,6 +16,8 @@ import redis.clients.jedis.UnifiedJedis
*/
class GameListRepositoryInRedis(
jedis: UnifiedJedis,
+ val projectionBus: GameProjectionBus,
+ val eventBus: GameEventBus,
) : GameListRepository {
private val projectionsRepository =
ProjectionRepositoryInRedis(
@@ -28,10 +29,7 @@ class GameListRepositoryInRedis(
jedis = jedis,
)
- fun subscribeToBus(
- projectionBus: GameProjectionBus,
- eventBus: GameEventBus,
- ) {
+ fun subscribeToBus() {
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt
similarity index 64%
rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt
index 02f46d4..6dad40a 100644
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInMemory.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInMemory.kt
@@ -1,7 +1,7 @@
-package eventDemo.adapter.infrastructure.event.projection
+package eventDemo.contexts.game.infrastructure.persistence.projections.bus
-import eventDemo.domain.event.projection.GameProjection
-import eventDemo.domain.event.projection.GameProjectionBus
+import eventDemo.contexts.game.application.ports.GameProjectionBus
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt
similarity index 74%
rename from src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt
index d067a61..4f20398 100644
--- a/src/main/kotlin/eventDemo/adapter/infrastructure/event/projection/GameProjectionBusInRabbitMQ.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/bus/GameProjectionBusInRabbitMQ.kt
@@ -1,8 +1,8 @@
-package eventDemo.adapter.infrastructure.event.projection
+package eventDemo.contexts.game.infrastructure.persistence.projections.bus
import com.rabbitmq.client.ConnectionFactory
-import eventDemo.domain.event.projection.GameProjection
-import eventDemo.domain.event.projection.GameProjectionBus
+import eventDemo.contexts.game.application.ports.GameProjectionBus
+import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt
similarity index 66%
rename from src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt
index 1b31a2f..72559ec 100644
--- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameList.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameList.kt
@@ -1,8 +1,8 @@
-package eventDemo.domain.event.projection
+package eventDemo.contexts.game.infrastructure.persistence.projections.models
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.libs.event.projection.Projection
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.libs.eventSource.projection.Projection
import kotlinx.serialization.Serializable
/**
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt
new file mode 100644
index 0000000..6882566
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/projections/models/GameProjection.kt
@@ -0,0 +1,8 @@
+package eventDemo.contexts.game.infrastructure.persistence.projections.models
+
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.libs.eventSource.projection.Projection
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface GameProjection : Projection
diff --git a/src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt
similarity index 91%
rename from src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt
index ebad2dd..cbe82e9 100644
--- a/src/main/kotlin/eventDemo/configuration/serializer/CommandIdSerializer.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/CommandIdSerializer.kt
@@ -1,4 +1,4 @@
-package eventDemo.configuration.serializer
+package eventDemo.contexts.game.infrastructure.persistence.serializers
import eventDemo.libs.command.CommandId
import kotlinx.serialization.KSerializer
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt
new file mode 100644
index 0000000..87096c8
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/EventIdSerializer.kt
@@ -0,0 +1,24 @@
+package eventDemo.contexts.game.infrastructure.persistence.serializers
+
+import eventDemo.libs.eventSource.EventId
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import java.util.UUID
+
+object EventIdSerializer : KSerializer {
+ override fun deserialize(decoder: Decoder): EventId =
+ EventId(UUID.fromString(decoder.decodeString()))
+
+ override fun serialize(
+ encoder: Encoder,
+ value: EventId,
+ ) {
+ encoder.encodeString(value.id.toString())
+ }
+
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("EventId", PrimitiveKind.STRING)
+}
diff --git a/src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt
similarity index 85%
rename from src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt
index d425738..9027285 100644
--- a/src/main/kotlin/eventDemo/configuration/serializer/GameIdSerializer.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/GameIdSerializer.kt
@@ -1,6 +1,6 @@
-package eventDemo.configuration.serializer
+package eventDemo.contexts.game.infrastructure.persistence.serializers
-import eventDemo.domain.entity.GameId
+import eventDemo.contexts.game.domain.game.GameId
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
diff --git a/src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt
similarity index 86%
rename from src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt
index 91559d4..977a25e 100644
--- a/src/main/kotlin/eventDemo/configuration/serializer/PlayerIdSerializer.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/persistence/serializers/PlayerIdSerializer.kt
@@ -1,6 +1,6 @@
-package eventDemo.configuration.serializer
+package eventDemo.contexts.game.infrastructure.persistence.serializers
-import eventDemo.domain.entity.Player
+import eventDemo.contexts.game.domain.game.Player
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
diff --git a/src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt
similarity index 82%
rename from src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt
rename to src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt
index c8718ff..73cc341 100644
--- a/src/main/kotlin/eventDemo/adapter/presenter/query/GameList.kt
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GameListRoute.kt
@@ -1,4 +1,4 @@
-package eventDemo.adapter.presenter.query
+package eventDemo.contexts.game.infrastructure.rest
import eventDemo.domain.event.projection.GameListRepository
import io.ktor.resources.Resource
@@ -15,7 +15,7 @@ class Games
/**
* API routes to show all games.
*/
-fun Route.readGamesList(gameListRepository: GameListRepository) {
+fun Route.gamesListRoute(gameListRepository: GameListRepository) {
authenticate {
// Read the last played card on the game.
get {
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt
new file mode 100644
index 0000000..d1fd887
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/rest/GetFullNotificationsRoute.kt
@@ -0,0 +1,45 @@
+package eventDemo.contexts.game.infrastructure.rest
+
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.application.notification.toNotification
+import eventDemo.contexts.game.application.ports.GameEventStore
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
+import eventDemo.sharedKernel.currentUserId
+import io.ktor.http.HttpStatusCode
+import io.ktor.resources.Resource
+import io.ktor.server.auth.authenticate
+import io.ktor.server.resources.get
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+import kotlinx.serialization.Serializable
+
+@Serializable
+@Resource("/games/{id}")
+class Game(
+ @Serializable(with = GameIdSerializer::class)
+ val id: GameId,
+)
+
+/**
+ * API routes to read the game state.
+ */
+fun Route.getFullNotificationsRoute(
+ gameRepository: GameRepository,
+ gameEventStore: GameEventStore,
+) {
+ authenticate {
+ get { body ->
+ val game =
+ gameRepository.get(body.id)
+ ?: return@get call.respond(HttpStatusCode.NotFound)
+ val notifications =
+ gameEventStore
+ .getStream(body.id)
+ .readAll()
+ .flatMap { it.toNotification(game, call.currentUserId) }
+
+ call.respond(notifications)
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt b/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt
new file mode 100644
index 0000000..2165181
--- /dev/null
+++ b/src/main/kotlin/eventDemo/contexts/game/infrastructure/websocket/GameCommandRouteWebSocket.kt
@@ -0,0 +1,26 @@
+package eventDemo.contexts.game.infrastructure.websocket
+
+import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.libs.helpers.fromFrameChannel
+import eventDemo.libs.helpers.toObjectChannel
+import eventDemo.sharedKernel.currentUserId
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.Route
+import io.ktor.server.websocket.webSocket
+import kotlinx.coroutines.DelicateCoroutinesApi
+import java.util.UUID
+
+@DelicateCoroutinesApi
+fun Route.gameWebSocket(channelSubscriber: GameChannelsSubscriber) {
+ authenticate {
+ webSocket("/games/{id}") {
+ channelSubscriber.subscribePlayerToGameChannels(
+ gameId = GameId(UUID.nameUUIDFromBytes(call.parameters["id"]?.encodeToByteArray()!!)),
+ userId = call.currentUserId,
+ incomingCommandChannel = toObjectChannel(incoming),
+ sendNotificationChannel = fromFrameChannel(outgoing),
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt b/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt
deleted file mode 100644
index d5d421d..0000000
--- a/src/main/kotlin/eventDemo/domain/command/GameCommandActionRunner.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package eventDemo.domain.command
-
-import eventDemo.domain.command.action.ICantPlay
-import eventDemo.domain.command.action.IWantToJoinTheGame
-import eventDemo.domain.command.action.IWantToPlayCard
-import eventDemo.domain.command.action.IamReadyToPlay
-import eventDemo.domain.command.command.GameCommand
-import eventDemo.domain.command.command.ICantPlayCommand
-import eventDemo.domain.command.command.IWantToJoinTheGameCommand
-import eventDemo.domain.command.command.IWantToPlayCardCommand
-import eventDemo.domain.command.command.IamReadyToPlayCommand
-import eventDemo.domain.event.event.GameEvent
-
-class GameCommandActionRunner(
- private val iWantToPlayCard: IWantToPlayCard,
- private val iamReadyToPlay: IamReadyToPlay,
- private val iWantToJoinTheGame: IWantToJoinTheGame,
- private val iCantPlay: ICantPlay,
-) {
- fun run(command: GameCommand): (version: Int) -> GameEvent =
- when (command) {
- is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
- is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
- is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
- is ICantPlayCommand -> iCantPlay.run(command)
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt b/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt
deleted file mode 100644
index 534af5c..0000000
--- a/src/main/kotlin/eventDemo/domain/command/GameCommandHandler.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-package eventDemo.domain.command
-
-import eventDemo.domain.command.command.GameCommand
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.GameEventStore
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.domain.notification.CommandErrorNotification
-import eventDemo.domain.notification.CommandSuccessNotification
-import eventDemo.domain.notification.Notification
-import eventDemo.libs.command.CommandHandler
-import eventDemo.libs.command.CommandRunnerController
-import eventDemo.libs.event.EventHandlerImpl
-import eventDemo.libs.event.VersionBuilder
-import io.github.oshai.kotlinlogging.KotlinLogging
-import io.github.oshai.kotlinlogging.withLoggingContext
-import kotlinx.coroutines.channels.ReceiveChannel
-import kotlinx.coroutines.channels.SendChannel
-
-/**
- * Listen [GameCommand] on [GameEventBus], check the validity and execute an action.
- *
- * This action can be executing an action and produce a new [GameEvent] after verification.
- */
-class GameCommandHandler(
- eventBus: GameEventBus,
- eventStore: GameEventStore,
- versionBuilder: VersionBuilder,
- runner: GameCommandActionRunner,
-) {
- private val logger = KotlinLogging.logger { }
-
- private val eventHandler =
- EventHandlerImpl(
- eventBus,
- eventStore,
- versionBuilder,
- )
- private val commandHandler =
- CommandHandler(
- CommandRunnerController(),
- eventHandler,
- ) {
- runner.run(it)
- }
-
- /**
- * Subscribe to the [event bus][GameEventBus]
- * to send success [notification][Notification] after save the [event][GameEvent].
- */
- fun subscribeToBus(eventBus: GameEventBus) =
- commandHandler.subscribeToBus(eventBus)
-
- /**
- * Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel],
- * run the command and publish the generated [event][GameEvent] to the bus.
- *
- * It restricts to run only once a command.
- *
- * If the command fail, send an [error notification][CommandErrorNotification],
- * if success, send a [success notification][CommandSuccessNotification]
- */
- suspend fun handleIncomingPlayerCommands(
- player: Player,
- gameId: GameId,
- incomingCommandChannel: ReceiveChannel,
- channelNotification: SendChannel,
- ) {
- for (command in incomingCommandChannel) {
- handle(
- player,
- gameId,
- command,
- channelNotification.sendSuccess(command),
- channelNotification.sendError(command),
- )
- }
- }
-
- /**
- * Run the [command] and publish the generated [event][GameEvent] to the bus.
- *
- * It restricts to run only once a command.
- *
- * If the command fail, send an [error notification][CommandErrorNotification],
- * if success, send a [success notification][CommandSuccessNotification]
- */
- fun handle(
- player: Player,
- gameId: GameId,
- command: GameCommand,
- sendSuccess: () -> Unit,
- sendError: (message: String) -> Unit,
- ) {
- if (command.payload.aggregateId.id != gameId.id) {
- logger.warn { "Handle command Refuse, the gameId of the command is not the same" }
- sendError("The gameId in the command does not match with your game")
- return
- }
- if (command.payload.player.id != player.id) {
- logger.warn { "Handle command Refuse, the player of the command is not the same" }
- sendError("You are not the author of this command")
- return
- }
-
- commandHandler.handle(gameId, command) { _, error ->
- if (error != null) {
- sendError(error.message) // Business
- } else {
- sendSuccess()
- }
- }
- }
-}
-
-private fun SendChannel.sendSuccess(command: GameCommand): () -> Unit =
- {
- val logger = KotlinLogging.logger { }
- CommandSuccessNotification(commandId = command.id)
- .also { notification ->
- withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) {
- logger.debug { "Notification SUCCESS sent" }
- trySend(notification)
- }
- }
- }
-
-private fun SendChannel.sendError(command: GameCommand): (message: String) -> Unit =
- {
- val logger = KotlinLogging.logger { }
- CommandErrorNotification(message = it, command = command)
- .also { notification ->
- withLoggingContext("notification" to notification.toString(), "command" to command.toString()) {
- logger.warn { "Notification ERROR sent: ${notification.message}" }
- trySend(notification)
- }
- }
- }
diff --git a/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt b/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt
deleted file mode 100644
index 35fee72..0000000
--- a/src/main/kotlin/eventDemo/domain/command/action/CommandAction.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package eventDemo.domain.command.action
-
-import eventDemo.libs.command.Command
-import eventDemo.libs.event.Event
-
-sealed interface CommandAction> {
- fun run(command: C): (version: Int) -> E
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt b/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt
deleted file mode 100644
index 0a9b284..0000000
--- a/src/main/kotlin/eventDemo/domain/command/action/ICantPlay.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package eventDemo.domain.command.action
-
-import eventDemo.domain.command.CommandException
-import eventDemo.domain.command.command.ICantPlayCommand
-import eventDemo.domain.event.event.PlayerHavePassEvent
-import eventDemo.domain.event.projection.GameStateRepository
-
-/**
- * A command to perform an action to play a new card
- */
-data class ICantPlay(
- private val gameStateRepository: GameStateRepository,
-) : CommandAction {
- override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent {
- val state = gameStateRepository.get(command.payload.aggregateId)
-
- if (state.currentPlayerTurn != command.payload.player) {
- throw CommandException("Its not your turn!")
- }
-
- val playableCards = state.playableCards(command.payload.player)
- if (playableCards.isNotEmpty()) {
- throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
- }
-
- val takenCard = state.deck.stack.first()
- return { version ->
- PlayerHavePassEvent(
- aggregateId = command.payload.aggregateId,
- player = command.payload.player,
- takenCard = takenCard,
- version = version,
- )
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt b/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt
deleted file mode 100644
index b0b99c4..0000000
--- a/src/main/kotlin/eventDemo/domain/command/action/IWantToJoinTheGame.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package eventDemo.domain.command.action
-
-import eventDemo.domain.command.CommandException
-import eventDemo.domain.command.command.IWantToJoinTheGameCommand
-import eventDemo.domain.event.event.NewPlayerEvent
-import eventDemo.domain.event.projection.GameStateRepository
-
-/**
- * A command to perform an action to play a new card
- */
-data class IWantToJoinTheGame(
- private val gameStateRepository: GameStateRepository,
-) : CommandAction {
- override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent {
- val state = gameStateRepository.get(command.payload.aggregateId)
- if (!state.isStarted) {
- return {
- NewPlayerEvent(
- aggregateId = command.payload.aggregateId,
- player = command.payload.player,
- version = it,
- )
- }
- } else {
- throw CommandException("The game is already started")
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt b/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt
deleted file mode 100644
index 25b27f5..0000000
--- a/src/main/kotlin/eventDemo/domain/command/action/IWantToPlayCard.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package eventDemo.domain.command.action
-
-import eventDemo.domain.command.CommandException
-import eventDemo.domain.command.command.IWantToPlayCardCommand
-import eventDemo.domain.event.event.CardIsPlayedEvent
-import eventDemo.domain.event.projection.GameStateRepository
-
-/**
- * A command to perform an action to play a new card
- */
-data class IWantToPlayCard(
- private val gameStateRepository: GameStateRepository,
-) : CommandAction {
- override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent {
- val state = gameStateRepository.get(command.payload.aggregateId)
-
- if (!state.isStarted) {
- throw CommandException("The game is Not started")
- }
- if (state.currentPlayerTurn != command.payload.player) {
- throw CommandException("Its not your turn!")
- }
- if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) {
- throw CommandException("You cannot play this card")
- }
-
- return { version ->
- CardIsPlayedEvent(
- aggregateId = command.payload.aggregateId,
- card = command.payload.card,
- player = command.payload.player,
- version = version,
- )
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt b/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt
deleted file mode 100644
index 5bc4c9d..0000000
--- a/src/main/kotlin/eventDemo/domain/command/action/IamReadyToPlay.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package eventDemo.domain.command.action
-
-import eventDemo.domain.command.CommandException
-import eventDemo.domain.command.command.IamReadyToPlayCommand
-import eventDemo.domain.event.event.PlayerReadyEvent
-import eventDemo.domain.event.projection.GameStateRepository
-
-/**
- * A command to set as ready to play
- */
-class IamReadyToPlay(
- private val gameStateRepository: GameStateRepository,
-) : CommandAction {
- @Throws(CommandException::class)
- override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent {
- val state = gameStateRepository.get(command.payload.aggregateId)
- val playerExist: Boolean = state.players.contains(command.payload.player)
- val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player)
-
- if (state.isStarted) {
- throw CommandException("The game is already started")
- } else if (!playerExist) {
- throw CommandException("You are not in the game")
- } else if (playerIsAlreadyReady) {
- throw CommandException("You are already ready")
- } else {
- return { version: Int ->
- PlayerReadyEvent(
- aggregateId = command.payload.aggregateId,
- player = command.payload.player,
- version = version,
- )
- }
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt
deleted file mode 100644
index a919392..0000000
--- a/src/main/kotlin/eventDemo/domain/command/command/GameCommand.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package eventDemo.domain.command.command
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.libs.command.Command
-import kotlinx.serialization.Serializable
-
-@Serializable
-sealed interface GameCommand : Command {
- val payload: Payload
-
- @Serializable
- sealed interface Payload {
- val aggregateId: GameId
- val player: Player
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt
deleted file mode 100644
index cbf49d0..0000000
--- a/src/main/kotlin/eventDemo/domain/command/command/IWantToJoinTheGameCommand.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package eventDemo.domain.command.command
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.libs.command.CommandId
-import kotlinx.serialization.Serializable
-
-/**
- * A command to perform an action to play a new card
- */
-@Serializable
-data class IWantToJoinTheGameCommand(
- override val payload: Payload,
-) : GameCommand {
- override val id: CommandId = CommandId()
-
- @Serializable
- data class Payload(
- override val aggregateId: GameId,
- override val player: Player,
- ) : GameCommand.Payload
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt
deleted file mode 100644
index 5f92f27..0000000
--- a/src/main/kotlin/eventDemo/domain/command/command/IWantToPlayCardCommand.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package eventDemo.domain.command.command
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.libs.command.CommandId
-import kotlinx.serialization.Serializable
-
-/**
- * A command to perform an action to play a new card
- */
-@Serializable
-data class IWantToPlayCardCommand(
- override val payload: Payload,
-) : GameCommand {
- override val id: CommandId = CommandId()
-
- @Serializable
- data class Payload(
- override val aggregateId: GameId,
- override val player: Player,
- val card: Card,
- ) : GameCommand.Payload
-}
diff --git a/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt
deleted file mode 100644
index dbc1b75..0000000
--- a/src/main/kotlin/eventDemo/domain/command/command/IamReadyToPlayCommand.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package eventDemo.domain.command.command
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.libs.command.CommandId
-import kotlinx.serialization.Serializable
-
-/**
- * A command to set as ready to play
- */
-@Serializable
-data class IamReadyToPlayCommand(
- override val payload: Payload,
-) : GameCommand {
- override val id: CommandId = CommandId()
-
- @Serializable
- data class Payload(
- override val aggregateId: GameId,
- override val player: Player,
- ) : GameCommand.Payload
-}
diff --git a/src/main/kotlin/eventDemo/domain/entity/Deck.kt b/src/main/kotlin/eventDemo/domain/entity/Deck.kt
deleted file mode 100644
index b0dfe17..0000000
--- a/src/main/kotlin/eventDemo/domain/entity/Deck.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-package eventDemo.domain.entity
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class Deck(
- val stack: Stack = Stack(),
- val discard: Discard = Discard(),
- val playersHands: PlayersHands = PlayersHands(),
-) {
- constructor(players: Set) :
- this(playersHands = PlayersHands(players))
-
- fun shuffle(): Deck =
- copy(stack = stack.shuffle())
-
- fun placeFirstCardOnDiscard(): Deck {
- val takenCard = stack.first()
- return copy(
- stack = stack - takenCard,
- discard = discard + takenCard,
- )
- }
-
- fun takeOneCardFromStackTo(player: Player): Deck =
- takeOne().let { (deck, newPlayerCard) ->
- deck.copy(
- playersHands = deck.playersHands.addCard(player, newPlayerCard),
- )
- }
-
- fun putOneCardFromHand(
- player: Player,
- card: Card,
- ): Deck =
- run {
- // Validate parameters
- val playerHand =
- playersHands.getHand(player)
- ?: error("No player on this game")
- if (playerHand.none { it == card }) {
- error("No card exist on the player hand")
- }
- }.let {
- copy(
- discard = discard + card,
- playersHands = playersHands.removeCard(player, card),
- )
- }
-
- fun playerHasNoCardLeft(): List =
- playersHands
- .filter { (playerId, hand) -> hand.isEmpty() }
- .map { (playerId, hand) -> playerId }
-
- private fun take(n: Int): Pair> {
- val takenCards = stack.take(n)
- val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
- return Pair(copy(stack = newStack), takenCards)
- }
-
- private fun takeOne(): Pair =
- take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
-
- companion object {
- fun newWithoutPlayers(): 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 {
- it + (1..4).map { Card.Plus4Card() }
- }.toStack()
- .let { Deck(it) }
- }
-}
-
-fun Deck.initHands(
- players: Set,
- handSize: Int = 7,
-): Deck {
- // Copy cards from stack to the player hands
- val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
- return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
- val hand = acc.stack.take(handSize)
- val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
- copy(
- stack = newStack,
- playersHands = acc.playersHands.addCards(player, hand),
- )
- }
-}
-
-@JvmInline
-@Serializable
-value class Stack(
- private val cards: Set = emptySet(),
-) : Set by cards {
- operator fun plus(card: Card): Stack =
- cards.plus(card).toStack()
-
- operator fun minus(card: Card): Stack =
- cards.minus(card).toStack()
-
- fun shuffle(): Stack =
- shuffled().toStack()
-}
-
-fun List.toStack(): Stack =
- Stack(this.toSet())
-
-fun Set.toStack(): Stack =
- Stack(this)
-
-@JvmInline
-@Serializable
-value class Discard(
- private val cards: Set = emptySet(),
-) : Set by cards {
- operator fun plus(card: Card): Discard =
- cards.plus(card).toDiscard()
-
- operator fun minus(card: Card): Discard =
- cards.minus(card).toDiscard()
-}
-
-fun List.toDiscard(): Discard =
- Discard(this.toSet())
-
-fun Set.toDiscard(): Discard =
- Discard(this)
diff --git a/src/main/kotlin/eventDemo/domain/entity/Player.kt b/src/main/kotlin/eventDemo/domain/entity/Player.kt
deleted file mode 100644
index 6f1c3ed..0000000
--- a/src/main/kotlin/eventDemo/domain/entity/Player.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package eventDemo.domain.entity
-
-import eventDemo.configuration.serializer.PlayerIdSerializer
-import eventDemo.configuration.serializer.UUIDSerializer
-import eventDemo.libs.event.AggregateId
-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()),
-) {
- constructor(id: String, name: String) : this(
- name,
- PlayerId(UUID.fromString(id)),
- )
-
- @Serializable
- @JvmInline
- value class PlayerId(
- @Serializable(with = UUIDSerializer::class)
- override val id: UUID = UUID.randomUUID(),
- ) : AggregateId {
- override fun toString(): String =
- id.toString()
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt b/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt
deleted file mode 100644
index 9884627..0000000
--- a/src/main/kotlin/eventDemo/domain/entity/PlayersHands.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package eventDemo.domain.entity
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-@JvmInline
-value class PlayersHands(
- private val map: Map> = emptyMap(),
-) : Map> by map {
- constructor(players: Set) :
- this(players.map { it.id }.associateWith { emptyList() }.toPlayersHands())
-
- fun getHand(player: Player): List? =
- this[player.id]
-
- fun removeCard(
- player: Player,
- card: Card,
- ): PlayersHands =
- mapValues { (playerId, cards) ->
- if (playerId == player.id) {
- if (!cards.contains(card)) error("The hand no contain the card")
- cards - card
- } else {
- cards
- }
- }.toPlayersHands()
-
- fun addCard(
- player: Player,
- newCard: Card,
- ): PlayersHands =
- addCards(player, listOf(newCard))
-
- fun addCards(
- player: Player,
- newCards: List,
- ): PlayersHands =
- mapValues { (p, cards) ->
- if (p == player.id) {
- if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
- cards + newCards
- } else {
- cards
- }
- }.toPlayersHands()
-}
-
-fun Map>.toPlayersHands(): PlayersHands =
- PlayersHands(this)
diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt b/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt
deleted file mode 100644
index 995ad14..0000000
--- a/src/main/kotlin/eventDemo/domain/event/GameEventBus.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package eventDemo.domain.event
-
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.bus.Bus
-
-interface GameEventBus : Bus
diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt b/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt
deleted file mode 100644
index 3f46a67..0000000
--- a/src/main/kotlin/eventDemo/domain/event/GameEventHandler.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package eventDemo.domain.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.event.EventHandler
-import eventDemo.libs.event.EventHandlerImpl
-import eventDemo.libs.event.VersionBuilder
-
-/**
- * Handle the event to dispatch it to store, bus and projections builders
- */
-class GameEventHandler(
- private val eventBus: GameEventBus,
- private val eventStore: GameEventStore,
- private val versionBuilder: VersionBuilder,
-) : EventHandler by EventHandlerImpl(eventBus, eventStore, versionBuilder)
diff --git a/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt b/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt
deleted file mode 100644
index fbef71f..0000000
--- a/src/main/kotlin/eventDemo/domain/event/GameEventStore.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package eventDemo.domain.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.event.EventStore
-
-interface GameEventStore : EventStore
diff --git a/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt
deleted file mode 100644
index d50df11..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/CardIsPlayedEvent.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * An [GameEvent] to represent a played card.
- */
-@Serializable
-data class CardIsPlayedEvent(
- override val aggregateId: GameId,
- val card: Card,
- override val player: Player,
- override val version: Int,
-) : GameEvent,
- PlayerActionEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt
deleted file mode 100644
index 58fe98c..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/GameEvent.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.libs.event.Event
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * An [Event] of a Game.
- */
-@Serializable
-sealed interface GameEvent : Event {
- override val eventId: UUID
- override val aggregateId: GameId
- override val version: Int
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt
deleted file mode 100644
index 5bdcb04..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/GameStartedEvent.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.Deck
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.domain.entity.initHands
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * This [GameEvent] is sent when all players are ready.
- */
-@Serializable
-data class GameStartedEvent(
- override val aggregateId: GameId,
- val firstPlayer: Player,
- val deck: Deck,
- override val version: Int,
-) : GameEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-
- companion object {
- fun new(
- id: GameId,
- players: Set,
- version: Int,
- shuffleIsDisabled: Boolean = isDisabled,
- ): GameStartedEvent =
- GameStartedEvent(
- aggregateId = id,
- firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
- deck =
- Deck
- .newWithoutPlayers()
- .let { if (shuffleIsDisabled) it else it.shuffle() }
- .initHands(players)
- .placeFirstCardOnDiscard(),
- version = version,
- )
- }
-}
-
-private var isDisabled = false
-
-internal fun disableShuffleDeck() {
- isDisabled = true
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt
deleted file mode 100644
index 5affe01..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/NewPlayerEvent.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * An [GameEvent] to represent a new player joining the game.
- */
-@Serializable
-data class NewPlayerEvent(
- override val aggregateId: GameId,
- val player: Player,
- override val version: Int,
-) : GameEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt
deleted file mode 100644
index 2f7eda8..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/PlayerActionEvent.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.Player
-import kotlinx.serialization.Serializable
-
-@Serializable
-sealed interface PlayerActionEvent : GameEvent {
- val player: Player
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt
deleted file mode 100644
index 4477e2a..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/PlayerChoseColorEvent.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * This [GameEvent] is sent when a player chose a color.
- */
-@Serializable
-data class PlayerChoseColorEvent(
- override val aggregateId: GameId,
- override val player: Player,
- val color: Card.Color,
- override val version: Int,
-) : GameEvent,
- PlayerActionEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt
deleted file mode 100644
index e646128..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/PlayerHavePassEvent.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * This [GameEvent] is sent when a player can play.
- */
-@Serializable
-data class PlayerHavePassEvent(
- override val aggregateId: GameId,
- override val player: Player,
- val takenCard: Card,
- override val version: Int,
-) : GameEvent,
- PlayerActionEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt
deleted file mode 100644
index 8f0afed..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/PlayerReadyEvent.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * This [GameEvent] is sent when a player is ready.
- */
-@Serializable
-data class PlayerReadyEvent(
- override val aggregateId: GameId,
- val player: Player,
- override val version: Int,
-) : GameEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt
deleted file mode 100644
index 96d73f7..0000000
--- a/src/main/kotlin/eventDemo/domain/event/event/PlayerWinEvent.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package eventDemo.domain.event.event
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-/**
- * This [GameEvent] is sent when a player is ready.
- */
-@Serializable
-data class PlayerWinEvent(
- override val aggregateId: GameId,
- val player: Player,
- override val version: Int,
-) : GameEvent {
- @Serializable(with = UUIDSerializer::class)
- override val eventId: UUID = UUID.randomUUID()
- override val createdAt: Instant = Clock.System.now()
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt b/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt
deleted file mode 100644
index 636aa54..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/GameProjection.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package eventDemo.domain.event.projection
-
-import eventDemo.domain.entity.GameId
-import eventDemo.libs.event.projection.Projection
-import kotlinx.serialization.Serializable
-
-@Serializable
-sealed interface GameProjection : Projection
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt b/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt
deleted file mode 100644
index e8bbe80..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/GameProjectionBus.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package eventDemo.domain.event.projection
-
-import eventDemo.libs.bus.Bus
-
-interface GameProjectionBus : Bus
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt
deleted file mode 100644
index 0ae729b..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/gameList/GameListBuilder.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package eventDemo.domain.event.projection
-
-import eventDemo.domain.event.event.CardIsPlayedEvent
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.domain.event.event.GameStartedEvent
-import eventDemo.domain.event.event.NewPlayerEvent
-import eventDemo.domain.event.event.PlayerChoseColorEvent
-import eventDemo.domain.event.event.PlayerHavePassEvent
-import eventDemo.domain.event.event.PlayerReadyEvent
-import eventDemo.domain.event.event.PlayerWinEvent
-
-fun GameList.apply(event: GameEvent): GameList =
- when (event) {
- is NewPlayerEvent -> {
- copy(
- players = players + event.player,
- status = GameList.Status.OPENING,
- )
- }
-
- is GameStartedEvent -> {
- copy(
- status = GameList.Status.IS_STARTED,
- )
- }
-
- is PlayerWinEvent -> {
- copy(
- winners = winners + event.player,
- status = GameList.Status.FINISH,
- )
- }
-
- is CardIsPlayedEvent -> {
- this
- }
-
- is PlayerChoseColorEvent -> {
- this
- }
-
- is PlayerHavePassEvent -> {
- this
- }
-
- is PlayerReadyEvent -> {
- this
- }
- }.copy(
- lastEventVersion = event.version,
- )
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt
deleted file mode 100644
index 914ee43..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameState.kt
+++ /dev/null
@@ -1,182 +0,0 @@
-package eventDemo.domain.event.projection
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.Deck
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.event.projection.Projection
-import kotlinx.serialization.Serializable
-
-/**
- * This [projection][Projection] is used for manage a game and theirs [card][Card]
- */
-@Serializable
-data class GameState(
- override val aggregateId: GameId,
- override val lastEventVersion: Int = 0,
- val players: Set = emptySet(),
- val currentPlayerTurn: Player? = null,
- val lastCardPlayer: Player? = null,
- val colorOnCurrentStack: Card.Color? = null,
- val direction: Direction = Direction.CLOCKWISE,
- val readyPlayers: Set = emptySet(),
- val deck: Deck = Deck(players),
- val isStarted: Boolean = false,
- val playerWins: Set = emptySet(),
- val lastEvent: GameEvent? = null,
-) : GameProjection {
- enum class Direction {
- CLOCKWISE,
- COUNTER_CLOCKWISE,
- ;
-
- fun revert(): Direction =
- if (this === CLOCKWISE) {
- COUNTER_CLOCKWISE
- } else {
- CLOCKWISE
- }
- }
-
- val cardOnCurrentStack: Card? = deck.discard.lastOrNull()
-
- val isReady: Boolean get() {
- return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
- }
-
- private val currentPlayerIndex: Int? get() {
- val i = players.indexOf(currentPlayerTurn)
- return if (i == -1) {
- null
- } else {
- i
- }
- }
-
- private fun nextPlayerIndex(direction: Direction): Int {
- if (players.isEmpty()) return 0
-
- return if (direction == Direction.CLOCKWISE) {
- sidePlayerIndexClockwise
- } else {
- sidePlayerIndexCounterClockwise
- }
- }
-
- fun nextPlayer(direction: Direction): Player =
- players.elementAt(nextPlayerIndex(direction))
-
- private val sidePlayerIndexClockwise: Int by lazy {
- if (players.isEmpty()) {
- 0
- } else {
- ((currentPlayerIndex ?: 0) + 1) % players.size
- }
- }
- private val sidePlayerIndexCounterClockwise: Int by lazy {
- if (players.isEmpty()) {
- 0
- } else {
- ((currentPlayerIndex ?: 0) - 1) % players.size
- }
- }
-
- val nextPlayerTurn: Player? by lazy {
- if (players.isEmpty()) {
- null
- } else {
- nextPlayer(direction)
- }
- }
-
- private val Player.currentIndex: Int get() = players.indexOf(this)
-
- 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 (lastCardPlayer == null) error("No card")
- return this.playerDiffIndex(lastCardPlayer) == 1
- }
-
- fun playableCards(player: Player): List =
- deck
- .playersHands
- .getHand(player)
- ?.filter { canBePlayThisCard(player, it) }
- ?: emptyList()
-
- fun playerHasNoCardLeft(): List =
- deck.playerHasNoCardLeft().map { playerId ->
- players.find { it.id == playerId } ?: error("inconsistency detected between players")
- }
-
- fun canBePlayThisCard(
- player: Player,
- card: Card,
- ): Boolean {
- val cardOnBoard = cardOnCurrentStack ?: return false
- return when (cardOnBoard) {
- is Card.NumericCard -> {
- when (card) {
- is Card.AllColorCard -> true
- is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
- is Card.ColorCard -> card.color == cardOnBoard.color
- }
- }
-
- is Card.ReverseCard -> {
- when (card) {
- is Card.ReverseCard -> true
- is Card.AllColorCard -> true
- is Card.ColorCard -> card.color == cardOnBoard.color
- }
- }
-
- is Card.PassCard -> {
- if (player.cardOnBoardIsForYou) {
- false
- } else {
- when (card) {
- is Card.AllColorCard -> true
- is Card.ColorCard -> card.color == cardOnBoard.color
- }
- }
- }
-
- is Card.ChangeColorCard -> {
- when (card) {
- is Card.AllColorCard -> true
- is Card.ColorCard -> card.color == colorOnCurrentStack
- }
- }
-
- is Card.Plus2Card -> {
- if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
- true
- } else {
- when (card) {
- is Card.Plus2Card -> true
- else -> false
- }
- }
- }
-
- is Card.Plus4Card -> {
- if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
- true
- } else {
- when (card) {
- is Card.AllColorCard -> true
- is Card.ColorCard -> card.color == colorOnCurrentStack
- }
- }
- }
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt
deleted file mode 100644
index 3b29dda..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateBuilder.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package eventDemo.domain.event.projection
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.event.event.CardIsPlayedEvent
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.domain.event.event.GameStartedEvent
-import eventDemo.domain.event.event.NewPlayerEvent
-import eventDemo.domain.event.event.PlayerActionEvent
-import eventDemo.domain.event.event.PlayerChoseColorEvent
-import eventDemo.domain.event.event.PlayerHavePassEvent
-import eventDemo.domain.event.event.PlayerReadyEvent
-import eventDemo.domain.event.event.PlayerWinEvent
-import io.github.oshai.kotlinlogging.KotlinLogging
-
-fun GameState.apply(event: GameEvent): GameState =
- this.let { state ->
- val logger = KotlinLogging.logger { }
- if (event is PlayerActionEvent) {
- if (state.currentPlayerTurn != event.player) {
- logger.atError {
- message = "Inconsistent player turn"
- payload =
- mapOf(
- "CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
- "Player" to event.player,
- )
- }
- }
- }
-
- when (event) {
- is CardIsPlayedEvent -> {
- val nextDirectionAfterPlay =
- when (event.card) {
- is Card.ReverseCard -> state.direction.revert()
- else -> state.direction
- }
-
- val color =
- when (event.card) {
- is Card.ColorCard -> event.card.color
- is Card.AllColorCard -> null
- }
-
- val currentPlayerAfterThePlay =
- if (event.card is Card.AllColorCard) {
- state.currentPlayerTurn
- } else {
- state.nextPlayer(nextDirectionAfterPlay)
- }
-
- state.copy(
- currentPlayerTurn = currentPlayerAfterThePlay,
- direction = nextDirectionAfterPlay,
- colorOnCurrentStack = color,
- lastCardPlayer = event.player,
- deck = state.deck.putOneCardFromHand(event.player, event.card),
- )
- }
-
- is NewPlayerEvent -> {
- if (state.isStarted) {
- logger.error { "The game is already started" }
- }
-
- state.copy(
- players = state.players + event.player,
- )
- }
-
- is PlayerReadyEvent -> {
- if (state.isStarted) {
- logger.error { "The game is already started" }
- }
- state.copy(
- readyPlayers = state.readyPlayers + event.player,
- )
- }
-
- is PlayerHavePassEvent -> {
- if (event.takenCard != state.deck.stack.first()) {
- logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
- }
- state.copy(
- currentPlayerTurn = state.nextPlayerTurn,
- deck = state.deck.takeOneCardFromStackTo(event.player),
- )
- }
-
- is PlayerChoseColorEvent -> {
- state.copy(
- currentPlayerTurn = state.nextPlayerTurn,
- colorOnCurrentStack = event.color,
- )
- }
-
- is GameStartedEvent -> {
- state.copy(
- colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
- lastCardPlayer = null,
- currentPlayerTurn = event.firstPlayer,
- deck = event.deck,
- isStarted = true,
- )
- }
-
- is PlayerWinEvent -> {
- state.copy(
- playerWins = state.playerWins + event.player,
- )
- }
- }.copy(
- lastEventVersion = event.version,
- lastEvent = event,
- )
- }
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt b/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt
deleted file mode 100644
index 1d1d8c6..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/gameState/GameStateRepository.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package eventDemo.domain.event.projection
-
-import eventDemo.domain.entity.GameId
-
-interface GameStateRepository {
- fun get(gameId: GameId): GameState
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt b/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt
deleted file mode 100644
index bc13f91..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/PlayerNotificationListener.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package eventDemo.domain.event.projection.projectionListener
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.domain.event.event.CardIsPlayedEvent
-import eventDemo.domain.event.event.GameStartedEvent
-import eventDemo.domain.event.event.NewPlayerEvent
-import eventDemo.domain.event.event.PlayerChoseColorEvent
-import eventDemo.domain.event.event.PlayerHavePassEvent
-import eventDemo.domain.event.event.PlayerReadyEvent
-import eventDemo.domain.event.event.PlayerWinEvent
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameState
-import eventDemo.domain.notification.ItsTheTurnOfNotification
-import eventDemo.domain.notification.Notification
-import eventDemo.domain.notification.PlayerAsJoinTheGameNotification
-import eventDemo.domain.notification.PlayerAsPlayACardNotification
-import eventDemo.domain.notification.PlayerHavePassNotification
-import eventDemo.domain.notification.PlayerWasChoseTheCardColorNotification
-import eventDemo.domain.notification.PlayerWasReadyNotification
-import eventDemo.domain.notification.PlayerWinNotification
-import eventDemo.domain.notification.TheGameWasStartedNotification
-import eventDemo.domain.notification.WelcomeToTheGameNotification
-import eventDemo.domain.notification.YourNewCardNotification
-import io.github.oshai.kotlinlogging.KotlinLogging
-import io.github.oshai.kotlinlogging.withLoggingContext
-
-class PlayerNotificationListener(
- private val projectionBus: GameProjectionBus,
-) {
- private val logger = KotlinLogging.logger {}
-
- /**
- * Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification]
- */
- fun startListening(
- currentPlayer: Player,
- gameId: GameId,
- outgoingNotification: (Notification) -> Unit,
- ): AutoCloseable {
- return projectionBus.subscribe { currentState ->
- if (currentState !is GameState) return@subscribe
- if (currentState.aggregateId != gameId) return@subscribe
- withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) {
- fun Notification.send() {
- withLoggingContext("notification" to this.toString()) {
- if (currentState.players.contains(currentPlayer)) {
- // Only notify players who have already joined the game.
- outgoingNotification(this)
- logger.info { "Notification was SEND" }
- } else {
- // Rare use case, when a connexion is created with the channel,
- // but the player was not already join in the game
- logger.warn { "Notification was SKIP, no player on the game" }
- }
- }
- }
-
- fun sendNextTurnNotif() =
- ItsTheTurnOfNotification(
- player = currentState.currentPlayerTurn ?: error("No player turn defined"),
- ).send()
-
- val event =
- currentState.lastEvent
- ?: error("No last event in the GameState projection")
-
- when (event) {
- is NewPlayerEvent -> {
- if (currentPlayer != event.player) {
- PlayerAsJoinTheGameNotification(
- player = event.player,
- ).send()
- } else {
- WelcomeToTheGameNotification(
- players = currentState.players,
- ).send()
- }
- }
-
- is CardIsPlayedEvent -> {
- if (currentPlayer != event.player) {
- PlayerAsPlayACardNotification(
- player = event.player,
- card = event.card,
- ).send()
- }
-
- if (event.card !is Card.AllColorCard) {
- ItsTheTurnOfNotification(
- player = currentState.currentPlayerTurn ?: error("No player turn defined"),
- ).send()
- }
- }
-
- is GameStartedEvent -> {
- TheGameWasStartedNotification(
- hand =
- event.deck.playersHands.getHand(currentPlayer)
- ?: error("You are not in the game"),
- ).send()
-
- sendNextTurnNotif()
- }
-
- is PlayerChoseColorEvent -> {
- if (currentPlayer != event.player) {
- PlayerWasChoseTheCardColorNotification(
- player = event.player,
- color = event.color,
- ).send()
- }
-
- sendNextTurnNotif()
- }
-
- is PlayerHavePassEvent -> {
- if (currentPlayer == event.player) {
- YourNewCardNotification(
- card = event.takenCard,
- ).send()
- } else {
- PlayerHavePassNotification(
- player = event.player,
- ).send()
- }
-
- sendNextTurnNotif()
- }
-
- is PlayerReadyEvent -> {
- if (currentPlayer != event.player) {
- PlayerWasReadyNotification(
- player = event.player,
- ).send()
- }
- }
-
- is PlayerWinEvent -> {
- PlayerWinNotification(
- player = event.player,
- ).send()
- }
- }
- }
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt b/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt
deleted file mode 100644
index c437132..0000000
--- a/src/main/kotlin/eventDemo/domain/event/projection/projectionListener/ReactionListener.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package eventDemo.domain.event.projection.projectionListener
-
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.event.GameEventHandler
-import eventDemo.domain.event.event.GameStartedEvent
-import eventDemo.domain.event.event.PlayerWinEvent
-import eventDemo.domain.event.projection.GameProjectionBus
-import eventDemo.domain.event.projection.GameState
-import eventDemo.libs.event.projection.Projection
-import io.github.oshai.kotlinlogging.KotlinLogging
-import io.github.oshai.kotlinlogging.withLoggingContext
-import java.util.concurrent.ConcurrentSkipListSet
-
-class ReactionListener(
- private val eventHandler: GameEventHandler,
-) {
- companion object Config {
- val registeredListeners = ConcurrentSkipListSet()
- }
-
- private val logger = KotlinLogging.logger { }
-
- fun subscribeToBus(projectionBus: GameProjectionBus) {
- if (registeredListeners.add(projectionBus)) {
- projectionBus.subscribe { projection: Projection ->
- if (projection !is GameState) return@subscribe
- withLoggingContext("projection" to projection.toString()) {
- sendStartGameEvent(projection)
- sendWinnerEvent(projection)
- }
- }
- } else {
- "${this::class.simpleName} is already init for this bus".let {
- logger.error { it }
- error(it)
- }
- }
- }
-
- private fun sendStartGameEvent(state: GameState) {
- if (state.isReady && !state.isStarted) {
- val reactionEvent =
- eventHandler.handle(state.aggregateId) {
- GameStartedEvent.new(
- id = state.aggregateId,
- players = state.players,
- version = it,
- )
- }
- logger.atInfo {
- message = "Reaction event was Send"
- payload = mapOf("reactionEvent" to reactionEvent)
- }
- }
- }
-
- private fun sendWinnerEvent(state: GameState) {
- val winner = state.playerHasNoCardLeft().firstOrNull()
- if (winner != null) {
- val reactionEvent =
- eventHandler.handle(state.aggregateId) {
- PlayerWinEvent(
- aggregateId = state.aggregateId,
- player = winner,
- version = it,
- )
- }
-
- logger.atInfo {
- message = "Reaction event was Send"
- payload = mapOf("reactionEvent" to reactionEvent)
- }
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt
deleted file mode 100644
index 3385315..0000000
--- a/src/main/kotlin/eventDemo/domain/notification/CommandErrorNotification.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package eventDemo.domain.notification
-
-import eventDemo.configuration.serializer.UUIDSerializer
-import eventDemo.libs.command.Command
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-@Serializable
-data class CommandErrorNotification(
- @Serializable(with = UUIDSerializer::class)
- override val id: UUID = UUID.randomUUID(),
- val message: String,
- val command: Command,
-) : Notification,
- CommandNotification
diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt
deleted file mode 100644
index 5d8462f..0000000
--- a/src/main/kotlin/eventDemo/domain/notification/CommandNotification.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package eventDemo.domain.notification
-
-sealed interface CommandNotification : Notification
diff --git a/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt b/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt
deleted file mode 100644
index 252774a..0000000
--- a/src/main/kotlin/eventDemo/domain/notification/CommandSuccessNotification.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package eventDemo.domain.notification
-
-import eventDemo.configuration.serializer.UUIDSerializer
-import eventDemo.libs.command.CommandId
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-@Serializable
-data class CommandSuccessNotification(
- @Serializable(with = UUIDSerializer::class)
- override val id: UUID = UUID.randomUUID(),
- val commandId: CommandId,
-) : Notification,
- CommandNotification
diff --git a/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt b/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt
deleted file mode 100644
index ef5d0b0..0000000
--- a/src/main/kotlin/eventDemo/domain/notification/PlayerWasChoseTheCardColorNotification.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package eventDemo.domain.notification
-
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.serializer.UUIDSerializer
-import kotlinx.serialization.Serializable
-import java.util.UUID
-
-@Serializable
-data class PlayerWasChoseTheCardColorNotification(
- @Serializable(with = UUIDSerializer::class)
- override val id: UUID = UUID.randomUUID(),
- val player: Player,
- val color: Card.Color,
-) : Notification
diff --git a/src/main/kotlin/eventDemo/libs/bus/Bus.kt b/src/main/kotlin/eventDemo/libs/bus/Bus.kt
index 89381eb..7b08a8c 100644
--- a/src/main/kotlin/eventDemo/libs/bus/Bus.kt
+++ b/src/main/kotlin/eventDemo/libs/bus/Bus.kt
@@ -6,6 +6,10 @@ interface Bus {
*/
fun publish(item: T)
+ fun publish(items: Collection) {
+ items.forEach { publish(it) }
+ }
+
/**
* Subscribe a [lambda][block] to the bus.
*
diff --git a/src/main/kotlin/eventDemo/libs/command/Command.kt b/src/main/kotlin/eventDemo/libs/command/Command.kt
index 39b966c..9faba9e 100644
--- a/src/main/kotlin/eventDemo/libs/command/Command.kt
+++ b/src/main/kotlin/eventDemo/libs/command/Command.kt
@@ -1,6 +1,6 @@
package eventDemo.libs.command
-import eventDemo.configuration.serializer.CommandIdSerializer
+import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
diff --git a/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt b/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt
deleted file mode 100644
index d192ea5..0000000
--- a/src/main/kotlin/eventDemo/libs/command/CommandHandler.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package eventDemo.libs.command
-
-import eventDemo.domain.command.CommandException
-import eventDemo.domain.command.command.GameCommand
-import eventDemo.domain.event.event.GameEvent
-import eventDemo.libs.bus.Bus
-import eventDemo.libs.event.AggregateId
-import eventDemo.libs.event.Event
-import eventDemo.libs.event.EventHandler
-import io.github.oshai.kotlinlogging.KotlinLogging
-import io.github.oshai.kotlinlogging.withLoggingContext
-import kotlinx.datetime.Clock
-import kotlinx.datetime.Instant
-import java.util.UUID
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.minutes
-
-/**
- * Listen [GameCommand] on [CommandStreamChannel], check the validity and execute an action.
- *
- * This action can be executing an action and produce a new [GameEvent] after verification.
- */
-class CommandHandler, E : Event, ID : AggregateId, C : Command>(
- private val controller: CommandRunnerController,
- private val eventHandler: EventHandler,
- private val runner: (command: C) -> (version: Int) -> E,
-) {
- private val logger = KotlinLogging.logger { }
- private val eventCommandMap = EventCommandMap()
-
- /** subscribe to the event bus to run callback after event was saved. */
- fun subscribeToBus(eventBus: B) {
- eventBus.subscribe { event: E ->
- eventCommandMap[event.eventId]?.invoke()
- ?: logger.debug { "No Notification for event: $event" }
- }
- }
-
- /**
- * Run the [command] and publish generated [event][Event].
- *
- * The [callback] is call after execute the [command]
- *
- * It restricts to run only once the [command].
- */
- fun handle(
- aggregateId: ID,
- command: C,
- callback: CommandCallback,
- ) {
- controller.runOnlyOnce(command) {
- withLoggingContext("command" to command.toString()) {
- logger.info { "Handle command" }
- try {
- val eventBuilder = runner(command)
-
- eventHandler.handle(aggregateId) { version ->
- eventBuilder(version)
- .also { eventCommandMap.set(callback, it, command) }
- }
- } catch (e: CommandException) {
- logger.warn(e) { e.message }
- callback(command, e)
- }
- }
- }
- }
-}
-
-/**
- * Map to record the command that triggered the event.
- */
-private class EventCommandMap>(
- val retention: Duration = 10.minutes,
-) {
- val map = ConcurrentHashMap>()
-
- fun set(
- callback: CommandCallback,
- event: E,
- command: C,
- ) {
- map[event.eventId] = Callback(callback, command, event, Clock.System.now())
-
- // Remove older
- map
- .filterValues { it.date < (Clock.System.now() - retention) }
- .keys
- .forEach(map::remove)
- }
-
- operator fun get(eventId: UUID): Callback? =
- map[eventId]
-
- data class Callback>(
- val callback: CommandCallback,
- val command: C,
- val event: E,
- val date: Instant,
- ) {
- operator fun invoke(error: CommandException? = null) {
- callback(command, error)
- }
- }
-}
-
-typealias CommandCallback = (command: C, error: CommandException?) -> Unit
diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt
deleted file mode 100644
index f9d18f7..0000000
--- a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package eventDemo.libs.command
-
-import io.github.oshai.kotlinlogging.KotlinLogging
-import io.github.oshai.kotlinlogging.withLoggingContext
-import kotlinx.coroutines.channels.ReceiveChannel
-
-/**
- * Manage [Command]'s with kotlin Channel.
- *
- * Use [CommandRunnerController] to prevent multiple executions.
- *
- * Add logs when command success or failed
- */
-class CommandStreamChannel(
- private val controller: CommandRunnerController,
-) {
- private val logger = KotlinLogging.logger {}
-
- suspend fun process(
- incoming: ReceiveChannel,
- action: CommandBlock,
- ) {
- for (command in incoming) {
- withLoggingContext("command" to command.toString()) {
- try {
- controller.runOnlyOnce(command) {
- // Wrap action to add logs
- runAndLogStatus(command, action)
- }
- } catch (e: CommandRunnerController.Exception) {
- logger.warn { e.message }
- }
- }
- }
- }
-
- private fun runAndLogStatus(
- command: C,
- action: CommandBlock,
- ) {
- val actionResult = runCatching { action(command) }
- if (actionResult.isFailure) {
- logger.warn(actionResult.exceptionOrNull()) { "Compute command FAILED" }
- } else if (actionResult.isSuccess) {
- logger.info { "Compute command SUCCESS" }
- }
- }
-}
-
-typealias CommandBlock = (C) -> Unit
diff --git a/src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt b/src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt
similarity index 76%
rename from src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt
rename to src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt
index ab36f1b..7d7e6b1 100644
--- a/src/main/kotlin/eventDemo/libs/command/CommandRunnerController.kt
+++ b/src/main/kotlin/eventDemo/libs/command/CommandUnicityChecker.kt
@@ -9,32 +9,32 @@ import kotlin.time.Duration.Companion.minutes
/**
* Controls the execution of a command to prevent it from being executed more than once.
*/
-class CommandRunnerController(
+class CommandUnicityChecker(
private val maxCacheTime: Duration = 10.minutes,
) {
private val executedCommand: ConcurrentHashMap> = ConcurrentHashMap()
fun runOnlyOnce(
command: C,
- action: CommandBlock,
+ action: (C) -> Unit,
) {
if (!isAlreadyExecuted(command)) {
action(command)
setAsExecuted(command)
removeOldCache()
} else {
- throw Exception("Command already executed", command)
+ throw UnicityException("Command already executed", command)
}
}
private fun setAsExecuted(command: C) {
- executedCommand.computeIfAbsent(command.id) { Pair(false, Clock.System.now()) }
+ executedCommand.computeIfAbsent(command.id) { Pair(true, Clock.System.now()) }
}
private fun removeOldCache() {
executedCommand
.filterValues { (_, date) ->
- (date + maxCacheTime) > Clock.System.now()
+ (date + maxCacheTime) < Clock.System.now()
}.keys
.forEach {
executedCommand.remove(it)
@@ -44,8 +44,8 @@ class CommandRunnerController(
private fun isAlreadyExecuted(command: C): Boolean =
executedCommand[command.id]?.first ?: false
- class Exception(
+ class UnicityException(
override val message: String,
val command: Command,
- ) : kotlin.Exception(message)
+ ) : Exception(message)
}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventHandler.kt b/src/main/kotlin/eventDemo/libs/event/EventHandler.kt
deleted file mode 100644
index 00eb39b..0000000
--- a/src/main/kotlin/eventDemo/libs/event/EventHandler.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package eventDemo.libs.event
-
-/**
- * A stream to publish and read the played card event.
- */
-interface EventHandler, ID : AggregateId> {
- fun handle(
- aggregateId: ID,
- buildEvent: (version: Int) -> E,
- ): E
-}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt b/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt
deleted file mode 100644
index d577530..0000000
--- a/src/main/kotlin/eventDemo/libs/event/EventHandlerImpl.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package eventDemo.libs.event
-
-import eventDemo.libs.bus.Bus
-import io.github.oshai.kotlinlogging.withLoggingContext
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.locks.ReentrantLock
-import kotlin.concurrent.withLock
-
-/**
- * Handle the event to dispatch it to store, bus and projections builders
- */
-class EventHandlerImpl, ID : AggregateId>(
- private val eventBus: Bus,
- private val eventStore: EventStore,
- private val versionBuilder: VersionBuilder,
-) : EventHandler {
- private val locks: ConcurrentHashMap = ConcurrentHashMap()
-
- /**
- * Build Event then send it to the event store and bus.
- */
- override fun handle(
- aggregateId: ID,
- buildEvent: (version: Int) -> E,
- ): E =
- withLoggingContext("aggregateId" to aggregateId.toString()) {
- locks
- // Get lock for the aggregate
- .computeIfAbsent(aggregateId) { ReentrantLock() }
- .withLock {
- // Build event with the version
- buildEvent(versionBuilder.buildNextVersion(aggregateId))
- // then publish it to the event store
- .also {
- withLoggingContext("event" to it.toString()) {
- eventStore.publish(it)
- eventBus.publish(it)
- }
- }
- }
- }
-}
diff --git a/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt b/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt
deleted file mode 100644
index 38ab05e..0000000
--- a/src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package eventDemo.libs.event
-
-interface VersionBuilder {
- fun buildNextVersion(aggregateId: AggregateId): Int
-
- fun getLastVersion(aggregateId: AggregateId): Int
-}
diff --git a/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt b/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt
deleted file mode 100644
index ab356fc..0000000
--- a/src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package eventDemo.libs.event
-
-import io.github.oshai.kotlinlogging.KotlinLogging
-import io.github.oshai.kotlinlogging.withLoggingContext
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicInteger
-
-class VersionBuilderLocal : VersionBuilder {
- private val logger = KotlinLogging.logger { }
- private val versions: ConcurrentHashMap = ConcurrentHashMap()
-
- override fun buildNextVersion(aggregateId: AggregateId): Int =
- withLoggingContext("aggregateId" to aggregateId.toString()) {
- versionOfAggregate(aggregateId)
- .addAndGet(1)
- .also { logger.debug { "New event version $it" } }
- }
-
- override fun getLastVersion(aggregateId: AggregateId): Int =
- versionOfAggregate(aggregateId).toInt()
-
- private fun versionOfAggregate(aggregateId: AggregateId) =
- versions
- .computeIfAbsent(aggregateId) { AtomicInteger(0) }
-}
diff --git a/src/main/kotlin/eventDemo/libs/event/Event.kt b/src/main/kotlin/eventDemo/libs/eventSource/Event.kt
similarity index 63%
rename from src/main/kotlin/eventDemo/libs/event/Event.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/Event.kt
index 749dc82..75c2391 100644
--- a/src/main/kotlin/eventDemo/libs/event/Event.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/Event.kt
@@ -1,4 +1,4 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource
import kotlinx.datetime.Instant
import java.util.UUID
@@ -13,11 +13,16 @@ interface AggregateId {
/**
* The basic interface for an Event
- * @see EventStream
+ * @see eventDemo.libs.eventSource.eventStore.EventStream
*/
interface Event {
- val eventId: UUID
+ val eventId: EventId
val aggregateId: ID
val createdAt: Instant
val version: Int
}
+
+@JvmInline
+value class EventId(
+ val id: UUID = UUID.randomUUID(),
+)
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStore.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt
similarity index 60%
rename from src/main/kotlin/eventDemo/libs/event/EventStore.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt
index bc7a75b..718c0d1 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStore.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStore.kt
@@ -1,5 +1,7 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import io.github.oshai.kotlinlogging.withLoggingContext
interface EventStore, ID : AggregateId> {
@@ -9,4 +11,7 @@ interface EventStore, ID : AggregateId> {
withLoggingContext("event" to event.toString()) {
getStream(event.aggregateId).publish(event)
}
+
+ fun publish(events: Set) =
+ events.forEach { publish(it) }
}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt
similarity index 75%
rename from src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt
index 9021a76..ac85b99 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStoreInMemory.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInMemory.kt
@@ -1,5 +1,7 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt
similarity index 65%
rename from src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt
index b15859e..1dc2882 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStoreInPostgresql.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStoreInPostgresql.kt
@@ -1,12 +1,15 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import javax.sql.DataSource
class EventStoreInPostgresql, ID : AggregateId>(
private val dataSource: DataSource,
private val objectToString: (E) -> String,
private val stringToObject: (String) -> E,
+ private val tableName: String,
) : EventStore {
override fun getStream(aggregateId: ID): EventStream =
- EventStreamInPostgresql(aggregateId, dataSource, objectToString, stringToObject)
+ EventStreamInPostgresql(aggregateId, dataSource, objectToString, stringToObject, tableName)
}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStream.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt
similarity index 82%
rename from src/main/kotlin/eventDemo/libs/event/EventStream.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt
index 5cf7a82..d92459c 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStream.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStream.kt
@@ -1,6 +1,8 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
-import eventDemo.libs.event.projection.Projection
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
+import eventDemo.libs.eventSource.projection.Projection
import io.github.oshai.kotlinlogging.withLoggingContext
/**
@@ -37,4 +39,6 @@ interface EventStream, ID : AggregateId> {
fun getByVersion(version: Int): E? =
readVersionBetween(version..version).firstOrNull()
+
+ fun exist(): Boolean
}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt
similarity index 83%
rename from src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt
index 48607ad..09c0005 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInMemory.kt
@@ -1,5 +1,7 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.Queue
import java.util.concurrent.ConcurrentLinkedQueue
@@ -34,4 +36,7 @@ class EventStreamInMemory, ID : AggregateId>(
events
.filter { version.contains(it.version) }
.toSet()
+
+ override fun exist(): Boolean =
+ events.isNotEmpty()
}
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt
similarity index 61%
rename from src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt
index fe81fe5..43df093 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStreamInPostgresql.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamInPostgresql.kt
@@ -1,6 +1,9 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import io.github.oshai.kotlinlogging.KotlinLogging
+import io.github.oshai.kotlinlogging.withLoggingContext
import org.postgresql.util.PGobject
import javax.sql.DataSource
@@ -14,31 +17,34 @@ class EventStreamInPostgresql, ID : AggregateId>(
private val dataSource: DataSource,
private val objectToString: (E) -> String,
private val stringToObject: (String) -> E,
+ private val tableName: String,
) : EventStream {
private val logger = KotlinLogging.logger {}
override fun publish(event: E) {
- if (event.aggregateId != aggregateId) {
- throw EventStreamPublishException(
- "You cannot publish this event in this stream because it has a different aggregateId!",
- )
+ withLoggingContext("event" to event.toString()) {
+ if (event.aggregateId != aggregateId) {
+ throw EventStreamPublishException(
+ "You cannot publish this event in this stream because it has a different aggregateId!",
+ )
+ }
+ dataSource.connection.use { connection ->
+ connection
+ .prepareStatement(
+ """
+ insert into $tableName (id, aggregate_id, version, data)
+ values (?, ?, ?, ?)
+ """.trimIndent(),
+ ).use {
+ it.setObject(1, event.eventId.id)
+ it.setObject(2, event.aggregateId.id)
+ it.setInt(3, event.version)
+ it.setObject(4, PGJsonb(objectToString(event)))
+ it.executeUpdate()
+ }
+ }
+ logger.info { "Event published" }
}
- dataSource.connection.use { connection ->
- connection
- .prepareStatement(
- """
- insert into event_stream(id, aggregate_id, version, data)
- values (?, ?, ?, ?)
- """.trimIndent(),
- ).use {
- it.setObject(1, event.eventId)
- it.setObject(2, event.aggregateId.id)
- it.setInt(3, event.version)
- it.setObject(4, PGJsonb(objectToString(event)))
- it.executeUpdate()
- }
- }
- logger.info { "Event published" }
}
override fun readAll(): Set =
@@ -46,8 +52,8 @@ class EventStreamInPostgresql, ID : AggregateId>(
connection
.prepareStatement(
"""
- select data
- from event_stream
+ select data
+ from $tableName
where aggregate_id = ?
order by version asc
""".trimIndent(),
@@ -66,13 +72,32 @@ class EventStreamInPostgresql, ID : AggregateId>(
}
}
+ override fun exist(): Boolean =
+ dataSource.connection.use { connection ->
+ connection
+ .prepareStatement(
+ """
+ select 1
+ from $tableName
+ where aggregate_id = ?
+ limit 1
+ order by version asc
+ """.trimIndent(),
+ ).use {
+ it.setObject(1, aggregateId.id)
+ it.executeQuery().use { resultSet ->
+ resultSet.next()
+ }
+ }
+ }
+
override fun readVersionBetween(version: IntRange): Set =
dataSource.connection.use { connection ->
connection
.prepareStatement(
"""
select data
- from event_stream
+ from $tableName
where version between ? and ?
and aggregate_id = ?
order by version asc
diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt
similarity index 66%
rename from src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt
index c2013f7..df58501 100644
--- a/src/main/kotlin/eventDemo/libs/event/EventStreamPublishException.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/eventStore/EventStreamPublishException.kt
@@ -1,4 +1,4 @@
-package eventDemo.libs.event
+package eventDemo.libs.eventSource.eventStore
class EventStreamPublishException(
override val message: String,
diff --git a/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/Projection.kt
similarity index 50%
rename from src/main/kotlin/eventDemo/libs/event/projection/Projection.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/projection/Projection.kt
index 1f969e7..f917acb 100644
--- a/src/main/kotlin/eventDemo/libs/event/projection/Projection.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/Projection.kt
@@ -1,6 +1,6 @@
-package eventDemo.libs.event.projection
+package eventDemo.libs.eventSource.projection
-import eventDemo.libs.event.AggregateId
+import eventDemo.libs.eventSource.AggregateId
interface Projection {
val aggregateId: ID
diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepository.kt
similarity index 81%
rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepository.kt
index ed48797..2db5a00 100644
--- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepository.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepository.kt
@@ -1,7 +1,7 @@
-package eventDemo.libs.event.projection
+package eventDemo.libs.eventSource.projection
-import eventDemo.libs.event.AggregateId
-import eventDemo.libs.event.Event
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
interface ProjectionRepository, P : Projection, ID : AggregateId> {
/**
diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryAbs.kt
similarity index 93%
rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryAbs.kt
index b349a82..e619da2 100644
--- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryAbs.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryAbs.kt
@@ -1,7 +1,7 @@
-package eventDemo.libs.event.projection
+package eventDemo.libs.eventSource.projection
-import eventDemo.libs.event.AggregateId
-import eventDemo.libs.event.Event
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInMemory.kt
similarity index 89%
rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInMemory.kt
index 3098c48..d1f4368 100644
--- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInMemory.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInMemory.kt
@@ -1,7 +1,7 @@
-package eventDemo.libs.event.projection
+package eventDemo.libs.eventSource.projection
-import eventDemo.libs.event.AggregateId
-import eventDemo.libs.event.Event
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import java.util.concurrent.ConcurrentHashMap
class ProjectionRepositoryInMemory, P : Projection, ID : AggregateId>(
diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInRedis.kt
similarity index 94%
rename from src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt
rename to src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInRedis.kt
index eba9554..43f5362 100644
--- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionRepositoryInRedis.kt
+++ b/src/main/kotlin/eventDemo/libs/eventSource/projection/ProjectionRepositoryInRedis.kt
@@ -1,7 +1,7 @@
-package eventDemo.libs.event.projection
+package eventDemo.libs.eventSource.projection
-import eventDemo.libs.event.AggregateId
-import eventDemo.libs.event.Event
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.eventSource.Event
import io.github.oshai.kotlinlogging.KotlinLogging
import redis.clients.jedis.UnifiedJedis
import redis.clients.jedis.params.ScanParams
diff --git a/src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt b/src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt
similarity index 86%
rename from src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt
rename to src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt
index e51fd3d..902dfcd 100644
--- a/src/main/kotlin/eventDemo/libs/FrameChannelConverter.kt
+++ b/src/main/kotlin/eventDemo/libs/helpers/FrameChannelConverter.kt
@@ -1,4 +1,4 @@
-package eventDemo.libs
+package eventDemo.libs.helpers
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame
@@ -25,8 +25,9 @@ inline fun CoroutineScope.toObjectChannel(
return produce(capacity = bufferSize) {
frames.consumeEach { frame ->
if (frame is Frame.Text) {
- logger.debug { "Conversion of the Frame: ${frame.readText()}" }
- send(Json.decodeFromString(frame.readText()))
+ val frameText = frame.readText()
+ logger.debug { "Conversion of the Frame: $frameText to ${T::class.simpleName}" }
+ send(Json.decodeFromString(frameText))
} else {
logger.warn { "The frame is not a text frame" }
}
diff --git a/src/main/kotlin/eventDemo/libs/ListToRange.kt b/src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt
similarity index 89%
rename from src/main/kotlin/eventDemo/libs/ListToRange.kt
rename to src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt
index 4db29c3..bf15b76 100644
--- a/src/main/kotlin/eventDemo/libs/ListToRange.kt
+++ b/src/main/kotlin/eventDemo/libs/helpers/ListToRange.kt
@@ -1,4 +1,4 @@
-package eventDemo.libs
+package eventDemo.libs.helpers
fun List.toRanges(): List =
fold(listOf()) { acc, i ->
diff --git a/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt b/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt
new file mode 100644
index 0000000..72dd2ed
--- /dev/null
+++ b/src/main/kotlin/eventDemo/libs/helpers/ReplaceValue.kt
@@ -0,0 +1,25 @@
+package eventDemo.libs.helpers
+
+inline fun Map.withReplacedValue(
+ toReplace: K,
+ transform: (V) -> V,
+): Map =
+ mapValues {
+ if (it.key == toReplace) {
+ transform(it.value)
+ } else {
+ it.value
+ }
+ }
+
+inline fun Set.withReplacedValue(
+ toReplace: V,
+ transform: (V) -> V,
+): Set =
+ map {
+ if (it == toReplace) {
+ transform(it)
+ } else {
+ it
+ }
+ }.toSet()
diff --git a/src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt b/src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt
similarity index 94%
rename from src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt
rename to src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt
index 6328f10..92b186d 100644
--- a/src/main/kotlin/eventDemo/configuration/serializer/UUIDSerializer.kt
+++ b/src/main/kotlin/eventDemo/libs/serializer/UUIDSerializer.kt
@@ -1,4 +1,4 @@
-package eventDemo.configuration.serializer
+package eventDemo.libs.serializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
diff --git a/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt b/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt
new file mode 100644
index 0000000..71098b0
--- /dev/null
+++ b/src/main/kotlin/eventDemo/sharedKernel/GetUserIdCredentials.kt
@@ -0,0 +1,12 @@
+package eventDemo.sharedKernel
+
+import io.ktor.server.application.ApplicationCall
+import io.ktor.server.auth.jwt.JWTPrincipal
+import io.ktor.server.auth.principal
+import java.util.UUID
+
+internal val ApplicationCall.currentUserId: UserId
+ get() =
+ principal()!!.run {
+ UserId(UUID.fromString(payload.getClaim("userid").asString()))
+ }
diff --git a/src/main/kotlin/eventDemo/sharedKernel/UserId.kt b/src/main/kotlin/eventDemo/sharedKernel/UserId.kt
new file mode 100644
index 0000000..7c6a20a
--- /dev/null
+++ b/src/main/kotlin/eventDemo/sharedKernel/UserId.kt
@@ -0,0 +1,13 @@
+package eventDemo.sharedKernel
+
+import eventDemo.libs.eventSource.AggregateId
+import eventDemo.libs.serializer.UUIDSerializer
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+@Serializable
+@JvmInline
+value class UserId(
+ @Serializable(with = UUIDSerializer::class)
+ override val id: UUID = UUID.randomUUID(),
+) : AggregateId
diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf
index 69b6a46..66a0f43 100644
--- a/src/main/resources/application.conf
+++ b/src/main/resources/application.conf
@@ -3,7 +3,7 @@ ktor {
port = 8080
}
application {
- modules = [ eventDemo.configuration.ConfigureKt.configure ]
+ modules = [ eventDemo.configuration.ConfigureKtorKt.configure ]
}
}
diff --git a/src/test/kotlin/eventDemo/CreateGameWithCommandsHelpers.kt b/src/test/kotlin/eventDemo/CreateGameWithCommandsHelpers.kt
new file mode 100644
index 0000000..e0314ea
--- /dev/null
+++ b/src/test/kotlin/eventDemo/CreateGameWithCommandsHelpers.kt
@@ -0,0 +1,60 @@
+package eventDemo
+
+import eventDemo.contexts.auth.domain.User
+import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
+import eventDemo.contexts.game.application.command.models.GameCommand
+import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
+import eventDemo.contexts.game.application.command.models.PlayCardCommand
+import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.sharedKernel.UserId
+import kotlinx.coroutines.channels.Channel
+import org.koin.core.Koin
+import java.util.UUID
+
+object GameWithCommands {
+ context(koin: Koin)
+ fun createGameWithCommands(
+ gameName: String = "testGame${UUID.randomUUID()}",
+ block: context(GameWithCommands, GameCommandHandlerDispatcher, GameId) ((User) -> Player) -> T,
+ ): T {
+ val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray()))
+ val repo = koin.get()
+ repo.getOrCreate(gameId)
+
+ return koin.get().run {
+ with(gameId) {
+ with(GameWithCommands) {
+ block { user -> repo.get(gameId)!!.players.get(user.id) }
+ }
+ }
+ }
+ }
+
+ context(dispatcher: GameCommandHandlerDispatcher, gameId: GameId)
+ fun User.joinTheGame(): JoinTheGameCommand =
+ JoinTheGameCommand(
+ id,
+ JoinTheGameCommand.Payload(gameId),
+ ).also { dispatcher.dispatch(it) }
+
+ context(dispatcher: GameCommandHandlerDispatcher, gameId: GameId)
+ fun Player.readyToPlay(): ReadyToPlayCommand =
+ ReadyToPlayCommand(
+ userId,
+ ReadyToPlayCommand.Payload(gameId, id),
+ ).also { dispatcher.dispatch(it) }
+
+ context(dispatcher: GameCommandHandlerDispatcher, gameId: GameId)
+ fun Player.playCard(
+ card: Card,
+ chosenColor: Card.Color? = null,
+ ): PlayCardCommand =
+ PlayCardCommand(
+ userId,
+ PlayCardCommand.Payload(gameId, id, card, chosenColor),
+ ).also { dispatcher.dispatch(it) }
+}
diff --git a/src/test/kotlin/eventDemo/CreateGameWithCommandsInChannelsHelpers.kt b/src/test/kotlin/eventDemo/CreateGameWithCommandsInChannelsHelpers.kt
new file mode 100644
index 0000000..fec264f
--- /dev/null
+++ b/src/test/kotlin/eventDemo/CreateGameWithCommandsInChannelsHelpers.kt
@@ -0,0 +1,60 @@
+package eventDemo
+
+import eventDemo.contexts.auth.domain.User
+import eventDemo.contexts.game.application.command.models.GameCommand
+import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
+import eventDemo.contexts.game.application.command.models.PlayCardCommand
+import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
+import eventDemo.contexts.game.application.eventStores.GameRepository
+import eventDemo.contexts.game.domain.game.Card
+import eventDemo.contexts.game.domain.game.GameId
+import eventDemo.contexts.game.domain.game.Player
+import eventDemo.sharedKernel.UserId
+import kotlinx.coroutines.channels.Channel
+import org.koin.core.Koin
+import java.util.UUID
+
+object GameWithCommandsInChannels {
+ context(koin: Koin)
+ suspend fun createGameWithCommandsInChannels(
+ channelCommand: Channel,
+ gameName: String = "testGame${UUID.randomUUID()}",
+ block: suspend context(Channel, GameId) ((User) -> Player) -> T,
+ ): T {
+ val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray()))
+ val repo = koin.get()
+ repo.getOrCreate(gameId)
+
+ return with(gameId) {
+ with(channelCommand) {
+ with(GameWithCommandsInChannels) {
+ block { user -> repo.get(gameId)!!.players.get(user.id) }
+ }
+ }
+ }
+ }
+
+ context(channelCommand: Channel, gameId: GameId)
+ suspend fun User.joinTheGame(): JoinTheGameCommand =
+ JoinTheGameCommand(
+ id,
+ JoinTheGameCommand.Payload(gameId),
+ ).also { channelCommand.send(it) }
+
+ context(channelCommand: Channel, gameId: GameId)
+ suspend fun Player.readyToPlay(): ReadyToPlayCommand =
+ ReadyToPlayCommand(
+ userId,
+ ReadyToPlayCommand.Payload(gameId, id),
+ ).also { channelCommand.send(it) }
+
+ context(channelCommand: Channel, gameId: GameId)
+ suspend fun Player.playCard(
+ card: Card,
+ chosenColor: Card.Color? = null,
+ ): PlayCardCommand =
+ PlayCardCommand(
+ userId,
+ PlayCardCommand.Payload(gameId, id, card, chosenColor),
+ ).also { channelCommand.send(it) }
+}
diff --git a/src/test/kotlin/eventDemo/NewUser.kt b/src/test/kotlin/eventDemo/NewUser.kt
new file mode 100644
index 0000000..487f3c9
--- /dev/null
+++ b/src/test/kotlin/eventDemo/NewUser.kt
@@ -0,0 +1,21 @@
+package eventDemo
+
+import eventDemo.contexts.auth.domain.User
+import eventDemo.sharedKernel.UserId
+import kotlin.reflect.KProperty
+
+class NewUser {
+ operator fun getValue(
+ thisRef: Any?,
+ property: KProperty<*>,
+ ): User =
+ newUser(property.name)
+}
+
+fun newUser(username: String): User =
+ User(
+ id = UserId(),
+ username = username,
+ password = "changeit",
+ version = 1,
+ )
diff --git a/src/test/kotlin/eventDemo/TestAllCardCountHelpers.kt b/src/test/kotlin/eventDemo/TestAllCardCountHelpers.kt
new file mode 100644
index 0000000..5f1dd07
--- /dev/null
+++ b/src/test/kotlin/eventDemo/TestAllCardCountHelpers.kt
@@ -0,0 +1,6 @@
+package eventDemo
+
+import eventDemo.contexts.game.domain.game.gameState.GameStarted
+
+fun GameStarted.allCardCount(): Int =
+ drawPile.remainingCards + discardPile.size + players.map { it.hand }.sumOf { it.size }
diff --git a/src/test/kotlin/eventDemo/Helpers.kt b/src/test/kotlin/eventDemo/TestApplicationHelpers.kt
similarity index 60%
rename from src/test/kotlin/eventDemo/Helpers.kt
rename to src/test/kotlin/eventDemo/TestApplicationHelpers.kt
index e9ba836..bd24ac9 100644
--- a/src/test/kotlin/eventDemo/Helpers.kt
+++ b/src/test/kotlin/eventDemo/TestApplicationHelpers.kt
@@ -1,11 +1,10 @@
package eventDemo
import com.zaxxer.hikari.HikariDataSource
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.Deck
-import eventDemo.configuration.domain.configureGameListener
-import eventDemo.configuration.injection.appKoinModule
-import eventDemo.configuration.ktor.configuration
+import eventDemo.configuration.appKoinModule
+import eventDemo.configuration.configuration
+import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener
+import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.server.config.ApplicationConfig
import io.ktor.server.testing.ApplicationTestBuilder
@@ -15,22 +14,15 @@ import org.koin.core.Koin
import org.koin.core.module.KoinApplicationDslMarker
import org.koin.dsl.koinApplication
import org.koin.ktor.ext.getKoin
-import redis.clients.jedis.UnifiedJedis
-import javax.sql.DataSource
-
-fun Deck.allCardCount(): Int =
- stack.size + discard.size + playersHands.values.flatten().size
-
-fun Deck.allCards(): Set =
- stack + discard + playersHands.values.flatten()
@KoinApplicationDslMarker
suspend fun testKoinApplicationWithConfig(block: suspend Koin.() -> T): T =
- koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration())) }
+ koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration)) }
.koin
.run {
cleanDataTest()
- configureGameListener()
+ configureProjectionListener()
+ configureReactionListener()
block()
.apply { get().close() }
}
@@ -62,23 +54,3 @@ fun testApplicationWithConfig(
logger.info { "B finish" }
}
}
-
-fun DataSource.cleanEventSource() {
- this.connection.use {
- it
- .prepareStatement(
- """
- truncate event_stream;
- """.trimIndent(),
- ).execute()
- }
-}
-
-fun UnifiedJedis.cleanProjections() {
- flushAll()
-}
-
-fun Koin.cleanDataTest() {
- get().cleanEventSource()
- get().cleanProjections()
-}
diff --git a/src/test/kotlin/eventDemo/TestDataHelper.kt b/src/test/kotlin/eventDemo/TestDataHelper.kt
new file mode 100644
index 0000000..7d55a54
--- /dev/null
+++ b/src/test/kotlin/eventDemo/TestDataHelper.kt
@@ -0,0 +1,27 @@
+package eventDemo
+
+import org.koin.core.Koin
+import redis.clients.jedis.UnifiedJedis
+import javax.sql.DataSource
+
+fun DataSource.cleanEventSource() {
+ this.connection.use {
+ it
+ .prepareStatement(
+ """
+ truncate game.game_event_stream;
+ truncate auth.user_event_stream;
+ truncate auth.user;
+ """.trimIndent(),
+ ).execute()
+ }
+}
+
+fun UnifiedJedis.cleanProjections() {
+ flushAll()
+}
+
+fun Koin.cleanDataTest() {
+ get().cleanEventSource()
+ get().cleanProjections()
+}
diff --git a/src/test/kotlin/eventDemo/TestHelper.kt b/src/test/kotlin/eventDemo/TestHelper.kt
new file mode 100644
index 0000000..a7ceaad
--- /dev/null
+++ b/src/test/kotlin/eventDemo/TestHelper.kt
@@ -0,0 +1,33 @@
+package eventDemo
+
+import io.kotest.assertions.nondeterministic.eventually
+import io.mockk.spyk
+import io.mockk.verify
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+inline fun arrange(block: () -> R): R =
+ run(block)
+
+inline fun T.and(block: (T) -> R): R =
+ this.let(block)
+
+inline fun T.act(block: (T) -> R): R =
+ this.let(block)
+
+inline fun T.assert(block: (T) -> R): R =
+ this.let(block)
+
+suspend fun spyPing(
+ duration: Duration = 0.5.seconds,
+ exactly: Int = -1,
+ block: (ping: () -> Unit) -> Unit,
+) {
+ val spy = spyk<() -> Unit>()
+
+ block(spy)
+
+ eventually(duration) {
+ verify(exactly = exactly) { spy() }
+ }
+}
diff --git a/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt b/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt
deleted file mode 100644
index 32f2246..0000000
--- a/src/test/kotlin/eventDemo/adapter/infrastructure/event/GameEventBusInRabbitMQTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package eventDemo.adapter.infrastructure.event
-
-import com.rabbitmq.client.ConnectionFactory
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.domain.event.GameEventBus
-import eventDemo.domain.event.event.NewPlayerEvent
-import eventDemo.testKoinApplicationWithConfig
-import io.kotest.assertions.nondeterministic.eventually
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.datatest.withData
-import io.kotest.matchers.equals.shouldBeEqual
-import io.mockk.mockk
-import io.mockk.spyk
-import io.mockk.verify
-import java.util.function.Function
-import kotlin.time.Duration.Companion.seconds
-
-class GameEventBusInRabbitMQTest :
- FunSpec({
- context("Pub/sub") {
- testKoinApplicationWithConfig {
- val busListToTest: Map =
- mapOf(
- GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(),
- GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get()),
- )
-
- withData(busListToTest) { bus ->
- val spy = spyk<() -> Unit>()
- val aggregateId = GameId()
- val player1 = Player(name = "Tesla")
- val player2 = Player(name = "Einstein")
-
- bus.subscribe { obj ->
- spy()
- obj.aggregateId shouldBeEqual aggregateId
- }
- bus.publish(NewPlayerEvent(aggregateId, player1, 1))
- bus.publish(NewPlayerEvent(aggregateId, player2, 2))
-
- eventually(1.seconds) {
- verify(exactly = 2) { spy() }
- }
- }
- }
- }
- })
diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt
deleted file mode 100644
index 55d8823..0000000
--- a/src/test/kotlin/eventDemo/adapter/presenter/query/AuthHelper.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package eventDemo.adapter.presenter.query
-
-import eventDemo.domain.entity.Player
-import eventDemo.configuration.ktor.makeJwt
-import io.ktor.client.request.HttpRequestBuilder
-import io.ktor.client.request.header
-
-internal fun HttpRequestBuilder.withAuth(player: Player) {
- header("Authorization", "Bearer ${player.makeJwt("secret")}")
-}
diff --git a/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt b/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt
deleted file mode 100644
index 6efdcfe..0000000
--- a/src/test/kotlin/eventDemo/adapter/presenter/query/GameSimulationTest.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-package eventDemo.adapter.presenter.query
-
-import eventDemo.Tag
-import eventDemo.domain.command.GameCommandHandler
-import eventDemo.domain.command.command.GameCommand
-import eventDemo.domain.command.command.IWantToJoinTheGameCommand
-import eventDemo.domain.command.command.IWantToPlayCardCommand
-import eventDemo.domain.command.command.IamReadyToPlayCommand
-import eventDemo.domain.entity.Card
-import eventDemo.domain.entity.GameId
-import eventDemo.domain.entity.Player
-import eventDemo.domain.event.event.disableShuffleDeck
-import eventDemo.domain.event.projection.GameState
-import eventDemo.domain.event.projection.GameStateRepository
-import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener
-import eventDemo.domain.notification.CommandSuccessNotification
-import eventDemo.domain.notification.ItsTheTurnOfNotification
-import eventDemo.domain.notification.Notification
-import eventDemo.domain.notification.PlayerAsJoinTheGameNotification
-import eventDemo.domain.notification.PlayerAsPlayACardNotification
-import eventDemo.domain.notification.PlayerWasReadyNotification
-import eventDemo.domain.notification.TheGameWasStartedNotification
-import eventDemo.domain.notification.WelcomeToTheGameNotification
-import eventDemo.testKoinApplicationWithConfig
-import io.kotest.assertions.nondeterministic.eventually
-import io.kotest.assertions.nondeterministic.until
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.equals.shouldBeEqual
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.channels.trySendBlocking
-import kotlinx.coroutines.joinAll
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeout
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-import kotlin.time.Duration.Companion.seconds
-
-@DelicateCoroutinesApi
-class GameSimulationTest :
- FunSpec({
- tags(Tag.Postgresql)
-
- test("Simulation of a game") {
- withTimeout(10.seconds) {
- disableShuffleDeck()
- val gameId = GameId()
- val player1 = Player(name = "Nikola")
- val player2 = Player(name = "Einstein")
- val channelCommand1 = Channel(Channel.BUFFERED)
- val channelCommand2 = Channel(Channel.BUFFERED)
- val channelNotification1 = Channel(Channel.BUFFERED)
- val channelNotification2 = Channel(Channel.BUFFERED)
-
- var playedCard1: Card? = null
- var playedCard2: Card? = null
-
- var player1HasJoin = false
-
- testKoinApplicationWithConfig {
- val commandHandler = get()
- val playerNotificationListener = get()
- val gameStateRepository = get()
-
- // Run command handler
- // In the normal process, these handlers is invoque players connect to the websocket
- run {
- GlobalScope.launch(Dispatchers.IO) {
- commandHandler.handleIncomingPlayerCommands(player1, gameId, channelCommand1, channelNotification1)
- }
- GlobalScope.launch(Dispatchers.IO) {
- commandHandler.handleIncomingPlayerCommands(player2, gameId, channelCommand2, channelNotification2)
- }
- }
-
- // Consume etch notification of players, and put theses in a list.
- // Is used later to control when other players can execute the next action
- val player1Notifications = mutableListOf()
- val player2Notifications = mutableListOf()
- run {
- GlobalScope.launch {
- for (notification in channelNotification1) {
- player1Notifications.add(notification)
- }
- }
-
- GlobalScope.launch {
- for (notification in channelNotification2) {
- player2Notifications.add(notification)
- }
- }
- }
-
- // Player 1 actions
- val player1Job =
- launch {
- playerNotificationListener.startListening(player1, gameId) {
- channelNotification1.trySendBlocking(it)
- }
- IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player1)).also { sendCommand ->
- channelCommand1.send(sendCommand)
- player1Notifications.waitNotification { commandId == sendCommand.id }
- }
-
- player1HasJoin = true
-
- player1Notifications.waitNotification { players == setOf(player1) }
- player1Notifications.waitNotification { player == player2 }
-
- IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(gameId, player1)).also { sendCommand ->
- channelCommand1.send(sendCommand)
- player1Notifications.waitNotification { commandId == sendCommand.id }
- }
- player1Notifications.waitNotification { player == player2 }
- val player1Hand = player1Notifications.waitNotification { hand.size == 7 }.hand
-
- playedCard1 = player1Hand.first()
- player1Notifications.waitNotification { player == player1 }
-
- IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(gameId, player1, player1Hand.first())).also { sendCommand ->
- channelCommand1.send(sendCommand)
- player1Notifications.waitNotification { commandId == sendCommand.id }
- }
-
- player1Notifications.waitNotification { player == player2 }
-
- player1Notifications.waitNotification {
- player == player2 && card == playedCard2
- }
- }
-
- // Player 2 actions
- val player2Job =
- launch {
- // wait player 1 has joined the game
- until(3.seconds) { player1HasJoin }
-
- playerNotificationListener.startListening(player2, gameId) {
- channelNotification2.trySendBlocking(it)
- }
-
- IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player2)).also { sendCommand ->
- channelCommand2.send(sendCommand)
- player2Notifications.waitNotification { commandId == sendCommand.id }
- }
-
- player2Notifications.waitNotification { players == setOf(player1, player2) }
- player2Notifications.waitNotification { player == player1 }
-
- IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(gameId, player2)).also { sendCommand ->
- channelCommand2.send(sendCommand)
- player2Notifications.waitNotification { commandId == sendCommand.id }
- }
-
- val player2Hand =
- player2Notifications.waitNotification { hand.size == 7 }.hand
-
- player2Notifications.waitNotification { player == player1 }
- player2Notifications.waitNotification {
- player == player1 && card == playedCard1
- }
- playedCard2 = player2Hand.first()
-
- player2Notifications.waitNotification { player == player2 }
-
- IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(gameId, player2, player2Hand.first())).also { sendCommand ->
- channelCommand2.send(sendCommand)
- player2Notifications.waitNotification { commandId == sendCommand.id }
- }
- }
-
- // Wait the end of the game
- joinAll(player1Job, player2Job)
-
- // Build the last state from the event store
- val state = gameStateRepository.get(gameId)
-
- // Check if the state is correct
- state.aggregateId shouldBeEqual gameId
- assertTrue(state.isStarted)
- state.players shouldBeEqual setOf(player1, player2)
- state.readyPlayers shouldBeEqual setOf(player1, player2)
- state.direction shouldBeEqual GameState.Direction.CLOCKWISE
- assertNotNull(state.lastCardPlayer) shouldBeEqual player2
- assertNotNull(state.cardOnCurrentStack) shouldBeEqual assertNotNull(playedCard2)
- }
- }
- }
- })
-
-private suspend inline fun MutableList.waitNotification(crossinline block: T.() -> Boolean): T =
- eventually(3.seconds) {
- filterIsInstance