refactoring: Masive refactor to build the V2
This commit is contained in:
@@ -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) },
|
||||
)
|
||||
-41
@@ -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)
|
||||
}
|
||||
-49
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+28
-20
@@ -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))
|
||||
+14
@@ -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()
|
||||
+22
@@ -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",
|
||||
)
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+9
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package eventDemo.domain.command
|
||||
package eventDemo.contexts.game.application.command.handlers
|
||||
|
||||
class CommandException(
|
||||
override val message: String,
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
}
|
||||
+24
@@ -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()
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -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
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+24
@@ -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,
|
||||
}
|
||||
+122
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+11
@@ -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
|
||||
+3
-3
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
+4
-4
@@ -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
|
||||
+4
-4
@@ -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
|
||||
+4
-4
@@ -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
|
||||
+4
-4
@@ -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
|
||||
+3
-3
@@ -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
|
||||
|
||||
+4
-4
@@ -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>
|
||||
+2
@@ -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()
|
||||
}
|
||||
+31
-13
@@ -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())
|
||||
+5
-4
@@ -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
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+18
@@ -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
Reference in New Issue
Block a user