refactoring: Masive refactor to build the V2

This commit is contained in:
2026-07-26 17:51:58 +02:00
parent 78956ce84e
commit f3b848ea93
250 changed files with 4340 additions and 4092 deletions
@@ -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<GameEvent, GameId> by EventStoreInMemory()
@@ -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<GameEvent, GameId> by EventStoreInPostgresql(
dataSource,
{ Json.encodeToString(it) },
{ Json.decodeFromString(it) },
)
@@ -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)
}
@@ -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)
}
@@ -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<GameCommand> = toObjectChannel(incoming)
val outgoingFrameChannel: SendChannel<Notification> = 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()
}
}
}
@@ -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<JWTPrincipal>()!!.run {
Player(
id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(),
)
}
@@ -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<Game.Card> { 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<Game.State> { body ->
val state = gameStateRepository.get(body.game.id)
call.respond(state)
}
}
}
@@ -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")
@@ -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()
}
@@ -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()
}
@@ -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)
}
@@ -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),
)
}
}
@@ -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()
}
@@ -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<GameCommandHandler>()
.subscribeToBus(get())
get<GameStateRepositoryInRedis>()
.subscribeToBus(get(), get())
get<GameListRepositoryInRedis>()
.subscribeToBus(get(), get())
get<ReactionListener>()
.subscribeToBus(get())
}
@@ -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,
)
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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")
@@ -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())
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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<UserEvent, UserId>
@@ -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?
}
@@ -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<UserEvent> = 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<UserEvent>): User? =
events.fold(null as User?) { acc, event ->
when (event) {
is NewUserCreatedEvent -> apply(event)
}
}
}
}
@@ -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()
}
@@ -0,0 +1,6 @@
package eventDemo.contexts.auth.domain.events
import eventDemo.libs.eventSource.Event
import eventDemo.sharedKernel.UserId
sealed interface UserEvent : Event<UserId>
@@ -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)
@@ -0,0 +1,8 @@
package eventDemo.contexts.auth.infrastructure.configure
import io.ktor.server.application.Application
fun Application.configureAuth() {
configureKtorAuth()
configureAuthRoutes()
}
@@ -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
}
@@ -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<UserEventStoreRepository>()
val userProjectionRepository = get<UserProjectionRepository>()
routing {
createUserRoute(userRepository)
loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository)
}
}
@@ -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))
@@ -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<UserEvent, UserId> by EventStoreInMemory()
@@ -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<UserEvent, UserId> by EventStoreInPostgresql(
dataSource,
{ Json.encodeToString(it) },
{ Json.decodeFromString(it) },
"auth.user_event_store",
)
@@ -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
}
}
@@ -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,
)
@@ -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)))
}
}
@@ -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<Users.Create> {
val passwordHash = hashPassword(it.password)
val user = User.createNewUser(it.username, passwordHash.result)
userRepository.save(user)
call.respond(
object {
val id = user.id.toString()
},
)
}
}
}
@@ -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<GameCommand>,
sendNotificationChannel: SendChannel<Notification>,
) {
val sub =
eventToNotificationSubscriber.subscribeToEventsAndSendNotification(
gameId = gameId,
currentUserId = userId,
outgoingFrameChannel = sendNotificationChannel,
)
commandSubscriber
.subscribe(
currentUserId = userId,
incomingFrameChannel = incomingCommandChannel,
).invokeOnCompletion { sub.close() }
}
}
@@ -1,4 +1,4 @@
package eventDemo.domain.command
package eventDemo.contexts.game.application.command.handlers
class CommandException(
override val message: String,
@@ -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<GameId, String> = 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)
}
}
}
}
@@ -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<C : Command> {
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 <G : Game> Game.isStatusOrFail(
kClass: KClass<G>,
message: String,
): G {
if (kClass.isInstance(this)) {
return this as G
} else {
throw CommandException(message)
}
}
}
@@ -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<JoinTheGameCommand> {
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()
}
}
@@ -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<PlayCardCommand> {
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()
}
}
@@ -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<ReadyToPlayCommand> {
override fun handle(command: ReadyToPlayCommand) {
command
.getGame()
.isStatusOrFail(GameCreated::class, "The game is started")
.setReadyPlayer(command.payload.playerId)
.saveEvents()
.publishEvents()
}
}
@@ -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<TakeCartFromDrawPileCommand> {
override fun handle(command: TakeCartFromDrawPileCommand) {
command
.getGame()
.isStatusOrFail(GameStarted::class, "The game is not started")
.playerTakeCartFromDrawPile(command.payload.playerId, 1)
.saveEvents()
.publishEvents()
}
}
@@ -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
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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) }
}
@@ -0,0 +1,29 @@
package eventDemo.contexts.game.application.logging
import io.github.oshai.kotlinlogging.withLoggingContext
inline fun <T> withLoggingContext(
vararg pair: Pair<LoggingContextKeys, *>,
body: () -> T,
): T =
withLoggingContext(
*pair
.map {
it.first.name to it.second.toString()
}.toTypedArray(),
restorePrevious = true,
body = body,
)
// inline fun withLoggingContext(
// vararg pair: Pair<LoggingContextKeys, *>,
// body: () -> Unit,
// ) =
// withLoggingContext(
// *pair
// .map {
// it.first.name to it.second.toString()
// }.toTypedArray(),
// restorePrevious = true,
// body = body,
// )
@@ -0,0 +1,9 @@
package eventDemo.contexts.game.application.logging
enum class LoggingContextKeys {
CurrentUserId,
Notification,
Game,
Event,
Command,
}
@@ -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<Notification> =
Iterable {
iterator {
val currentPlayerId = game.players.get(currentUserId).id
context(iterator: SequenceScope<Notification>)
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,
)
}
}
}
}
@@ -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<Notification>,
): 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<GameCommand>()
@DelicateCoroutinesApi
fun subscribe(
currentUserId: UserId,
incomingFrameChannel: ReceiveChannel<GameCommand>,
): Job =
GlobalScope.launch {
for (command in incomingFrameChannel) {
withLoggingContext(CurrentUserId to currentUserId, Command to command) {
controller.runOnlyOnce(command) {
gameCommandHandlerDispatcher.dispatch(command)
}
}
}
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<Card>,
val hand: Set<Card>,
) : Notification
@@ -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
@@ -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<Card>,
) : Notification
@@ -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<GameEvent>
@@ -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<GameEvent, GameId>
@@ -1,5 +1,7 @@
package eventDemo.domain.event.projection
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameList
interface GameListRepository {
fun getList(): List<GameList>
}
@@ -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<GameProjection>
@@ -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,
)
@@ -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<GameEventBus>()
}
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()
}
}
}
}
@@ -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
}
@@ -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()
}
@@ -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()
}
@@ -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<GameId> {
@Serializable(with = EventIdSerializer::class)
override val eventId: EventId
@Serializable(with = GameIdSerializer::class)
override val aggregateId: GameId
override val version: Int
}
@@ -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
}
@@ -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()
}
@@ -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
}
@@ -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<Card>,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = EventIdSerializer::class)
override val eventId: EventId = EventId(UUID.randomUUID())
override val createdAt: Instant = Clock.System.now()
}
@@ -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()
}
@@ -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()
}
@@ -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"
}
}
@@ -0,0 +1,18 @@
package eventDemo.contexts.game.domain.game
import kotlinx.serialization.Serializable
@JvmInline
@Serializable
value class DiscardPile(
val cards: Set<Card> = 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
}
@@ -0,0 +1,31 @@
package eventDemo.contexts.game.domain.game
import kotlinx.serialization.Serializable
@JvmInline
@Serializable
value class DrawPile(
val cards: Set<Card> = emptySet(),
) {
fun take(number: Int): Pair<DrawPile, Set<Card>> =
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<Card>.toDrawPile(): DrawPile =
DrawPile(this.toSet())
@@ -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 =
@@ -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<Player> = emptySet(),
) : Set<Player> 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<Card>,
): 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)
}
@@ -0,0 +1,18 @@
package eventDemo.contexts.game.domain.game
import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class PlayerHand(
val cards: Set<Card> = emptySet(),
) {
fun withNewCards(newCards: Set<Card>): PlayerHand =
PlayerHand(cards + newCards)
fun withoutTheCards(newCards: Set<Card>): PlayerHand =
PlayerHand(cards - newCards)
val size: Int get() =
cards.size
}
@@ -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<Card>,
) : 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<GameEvent>,
) : GameException("Inconsistent event version")
@@ -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<GameEvent>
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<GameEvent>): 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 : GameEvent> 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)
}
}
}
@@ -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<Player.PlayerId, PlayerStatus> = emptyMap(),
override val recordedEvents: Set<GameEvent>,
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<Card>): Pair<DrawPile, DiscardPile> =
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<Card>
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<Card>.shuffleDeck() =
shuffled().toSet()
@@ -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<Player.PlayerId> = emptySet(),
override val version: Int,
override val recordedEvents: Set<GameEvent>,
) : Game {
init {
if (!players.map { it.id }.containsAll(playerWins)) {
throw IllegalArgumentException("Player ${players.map { it.id }} were not in players")
}
checkRecorderEventsConsistency()
}
}
@@ -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<GameEvent> = 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)
}
@@ -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<History> = emptyList(),
val direction: Direction = Direction.CLOCKWISE,
val playerWins: Set<Player.PlayerId> = emptySet(),
override val version: Int,
override val recordedEvents: Set<GameEvent>,
) : 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<Card> =
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 : PlayerActionEvent> T.checkPlayerTurn(): T =
checkState(
{ nextPlayer.id == playerId },
{ ItsNotTheTurnException(playerId, this) },
)
private fun isPlayedLastTurn(card: Card): Boolean =
(playedTurnHistory.last().event as? CardIsPlayedEvent)?.card == card
}
@@ -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
}
@@ -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)
}

Some files were not shown because too many files have changed in this diff Show More