diff --git a/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt b/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt index 36fc334..f2ac542 100644 --- a/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt +++ b/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt @@ -1,15 +1,14 @@ package eventDemo.app.command -import eventDemo.app.GameState import eventDemo.app.command.command.GameCommand import eventDemo.app.command.command.ICantPlayCommand import eventDemo.app.command.command.IWantToJoinTheGameCommand import eventDemo.app.command.command.IWantToPlayCardCommand import eventDemo.app.command.command.IamReadyToPlayCommand import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStream +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.event.GameEvent +import eventDemo.app.event.projection.GameStateRepository import eventDemo.app.notification.ErrorNotification import eventDemo.shared.toFrame import io.github.oshai.kotlinlogging.KotlinLogging @@ -24,7 +23,8 @@ import kotlinx.coroutines.channels.trySendBlocking * This action can be executing an action and produce a new [GameEvent] after verification. */ class GameCommandHandler( - private val eventStream: GameEventStream, + private val eventHandler: GameEventHandler, + private val gameStateRepository: GameStateRepository, ) { private val logger = KotlinLogging.logger { } @@ -58,16 +58,14 @@ class GameCommandHandler( nack() } - val gameState = command.buildGameState() + val gameState = gameStateRepository.get(command.payload.gameId) when (command) { - is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventStream) - is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream) - is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventStream) - is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream) + is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventHandler) + is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler) + is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventHandler) + is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler) } } } - - private fun GameCommand.buildGameState(): GameState = payload.gameId.buildStateFromEventStream(eventStream) } diff --git a/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt b/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt index f90d059..cfc8590 100644 --- a/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt @@ -1,10 +1,10 @@ package eventDemo.app.command.command -import eventDemo.app.GameState import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventStream +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.event.PlayerHavePassEvent +import eventDemo.app.event.projection.GameState import eventDemo.libs.command.CommandId import kotlinx.serialization.Serializable @@ -26,13 +26,13 @@ data class ICantPlayCommand( fun run( state: GameState, playerErrorNotifier: (String) -> Unit, - eventStream: GameEventStream, + eventHandler: GameEventHandler, ) { val playableCards = state.playableCards(payload.player) if (playableCards.isEmpty()) { val takenCard = state.deck.stack.first() - eventStream.publish( + eventHandler.handle( PlayerHavePassEvent( gameId = payload.gameId, player = payload.player, diff --git a/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt index f96177a..bcd4695 100644 --- a/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt @@ -1,10 +1,10 @@ package eventDemo.app.command.command -import eventDemo.app.GameState import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventStream +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.event.NewPlayerEvent +import eventDemo.app.event.projection.GameState import eventDemo.libs.command.CommandId import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.serialization.Serializable @@ -27,11 +27,11 @@ data class IWantToJoinTheGameCommand( fun run( state: GameState, playerErrorNotifier: (String) -> Unit, - eventStream: GameEventStream, + eventHandler: GameEventHandler, ) { val logger = KotlinLogging.logger {} if (!state.isStarted) { - eventStream.publish( + eventHandler.handle( NewPlayerEvent( payload.gameId, payload.player, diff --git a/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt index 0acaf23..2b2e664 100644 --- a/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt @@ -1,11 +1,11 @@ package eventDemo.app.command.command -import eventDemo.app.GameState import eventDemo.app.entity.Card import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventStream +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.event.CardIsPlayedEvent +import eventDemo.app.event.projection.GameState import eventDemo.libs.command.CommandId import kotlinx.serialization.Serializable @@ -28,7 +28,7 @@ data class IWantToPlayCardCommand( fun run( state: GameState, playerErrorNotifier: (String) -> Unit, - eventStream: GameEventStream, + eventHandler: GameEventHandler, ) { if (!state.isStarted) { playerErrorNotifier("The game is Not started") @@ -36,7 +36,7 @@ data class IWantToPlayCardCommand( } if (state.canBePlayThisCard(payload.player, payload.card)) { - eventStream.publish( + eventHandler.handle( CardIsPlayedEvent( payload.gameId, payload.card, diff --git a/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt index 38f54db..0883032 100644 --- a/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt @@ -1,10 +1,10 @@ package eventDemo.app.command.command -import eventDemo.app.GameState import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventStream +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.event.PlayerReadyEvent +import eventDemo.app.event.projection.GameState import eventDemo.libs.command.CommandId import kotlinx.serialization.Serializable @@ -26,7 +26,7 @@ data class IamReadyToPlayCommand( fun run( state: GameState, playerErrorNotifier: (String) -> Unit, - eventStream: GameEventStream, + eventHandler: GameEventHandler, ) { val playerExist: Boolean = state.players.contains(payload.player) val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player) @@ -36,7 +36,7 @@ data class IamReadyToPlayCommand( } else if (playerIsAlreadyReady) { playerErrorNotifier("You are already ready") } else { - eventStream.publish( + eventHandler.handle( PlayerReadyEvent( payload.gameId, payload.player, diff --git a/src/main/kotlin/eventDemo/app/entity/Deck.kt b/src/main/kotlin/eventDemo/app/entity/Deck.kt index 9d9fc51..b1a9e30 100644 --- a/src/main/kotlin/eventDemo/app/entity/Deck.kt +++ b/src/main/kotlin/eventDemo/app/entity/Deck.kt @@ -47,6 +47,11 @@ data class Deck( ) } + fun playerHasNoCardLeft(): List = + playersHands + .filter { (playerId, hand) -> hand.isEmpty() } + .map { (playerId, hand) -> playerId } + private fun take(n: Int): Pair> { val takenCards = stack.take(n) val newStack = stack.filterNot { takenCards.contains(it) }.toStack() diff --git a/src/main/kotlin/eventDemo/app/event/GameEventHandler.kt b/src/main/kotlin/eventDemo/app/event/GameEventHandler.kt new file mode 100644 index 0000000..bc208c4 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/GameEventHandler.kt @@ -0,0 +1,27 @@ +package eventDemo.app.event + +import eventDemo.app.event.event.GameEvent + +/** + * A stream to publish and read the played card event. + */ +class GameEventHandler( + private val eventBus: GameEventBus, + private val eventStream: GameEventStream, +) { + private val projectionsBuilders: MutableList<(GameEvent) -> Unit> = mutableListOf() + + fun registerProjectionBuilder(builder: GameProjectionBuilder) { + projectionsBuilders.add(builder) + } + + fun handle(vararg events: GameEvent) { + events.forEach { event -> + eventStream.publish(event) + projectionsBuilders.forEach { it(event) } + eventBus.publish(event) + } + } +} + +typealias GameProjectionBuilder = (GameEvent) -> Unit diff --git a/src/main/kotlin/eventDemo/app/event/GameEventStream.kt b/src/main/kotlin/eventDemo/app/event/GameEventStream.kt index 317f275..9a02310 100644 --- a/src/main/kotlin/eventDemo/app/event/GameEventStream.kt +++ b/src/main/kotlin/eventDemo/app/event/GameEventStream.kt @@ -8,11 +8,9 @@ import eventDemo.libs.event.EventStream * A stream to publish and read the played card event. */ class GameEventStream( - private val eventBus: GameEventBus, private val eventStream: EventStream, ) : EventStream by eventStream { override fun publish(event: GameEvent) { eventStream.publish(event) - eventBus.publish(event) } } diff --git a/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt b/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt index c71ee52..cdbcd1b 100644 --- a/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt @@ -17,14 +17,15 @@ data class GameStartedEvent( fun new( id: GameId, players: Set, + shuffleIsDisabled: Boolean = isDisabled, ): GameStartedEvent = GameStartedEvent( gameId = id, - firstPlayer = if (isDisabled) players.first() else players.random(), + firstPlayer = if (shuffleIsDisabled) players.first() else players.random(), deck = Deck .newWithoutPlayers() - .let { if (isDisabled) it else it.shuffle() } + .let { if (shuffleIsDisabled) it else it.shuffle() } .initHands(players) .placeFirstCardOnDiscard(), ) diff --git a/src/main/kotlin/eventDemo/app/event/event/PlayerWinEvent.kt b/src/main/kotlin/eventDemo/app/event/event/PlayerWinEvent.kt new file mode 100644 index 0000000..35647ca --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerWinEvent.kt @@ -0,0 +1,12 @@ +package eventDemo.app.event.event + +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player + +/** + * This [GameEvent] is sent when a player is ready. + */ +data class PlayerWinEvent( + override val gameId: GameId, + val player: Player, +) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/GameState.kt b/src/main/kotlin/eventDemo/app/event/projection/GameState.kt similarity index 94% rename from src/main/kotlin/eventDemo/app/GameState.kt rename to src/main/kotlin/eventDemo/app/event/projection/GameState.kt index b20be82..070f5f6 100644 --- a/src/main/kotlin/eventDemo/app/GameState.kt +++ b/src/main/kotlin/eventDemo/app/event/projection/GameState.kt @@ -1,4 +1,4 @@ -package eventDemo.app +package eventDemo.app.event.projection import eventDemo.app.entity.Card import eventDemo.app.entity.Deck @@ -17,6 +17,7 @@ data class GameState( val readyPlayers: Set = emptySet(), val deck: Deck = Deck(players), val isStarted: Boolean = false, + val playerWins: Set = emptySet(), ) { @Serializable data class LastCard( @@ -92,6 +93,11 @@ data class GameState( ?.filter { canBePlayThisCard(player, it) } ?: emptyList() + fun playerHasNoCardLeft(): List = + deck.playerHasNoCardLeft().map { playerId -> + players.find { it.id == playerId } ?: error("inconsistency detected between players") + } + fun canBePlayThisCard( player: Player, card: Card, diff --git a/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt b/src/main/kotlin/eventDemo/app/event/projection/GameStateBuilder.kt similarity index 82% rename from src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt rename to src/main/kotlin/eventDemo/app/event/projection/GameStateBuilder.kt index 80235cd..11c30bf 100644 --- a/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt +++ b/src/main/kotlin/eventDemo/app/event/projection/GameStateBuilder.kt @@ -1,8 +1,8 @@ -package eventDemo.app.event +package eventDemo.app.event.projection -import eventDemo.app.GameState import eventDemo.app.entity.Card import eventDemo.app.entity.GameId +import eventDemo.app.event.GameEventStream import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameStartedEvent @@ -10,19 +10,33 @@ import eventDemo.app.event.event.NewPlayerEvent import eventDemo.app.event.event.PlayerChoseColorEvent import eventDemo.app.event.event.PlayerHavePassEvent import eventDemo.app.event.event.PlayerReadyEvent +import eventDemo.app.event.event.PlayerWinEvent fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState = buildStateFromEvents( eventStream.readAll(this), ) +/** + * Build the state to the specific event + */ fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState = gameId.buildStateFromEvents( eventStream.readAll(gameId).takeWhile { it != this } + this, ) private fun GameId.buildStateFromEvents(events: List): GameState = - events.fold(GameState(this)) { state: GameState, event: GameEvent -> + events.fold(GameState(this)) { state, event -> + state.apply(event) + } + +fun List.buildStateFromEvents(): GameState = + fold(GameState(this.first().gameId)) { state, event -> + state.apply(event) + } + +fun GameState.apply(event: GameEvent): GameState = + let { state -> when (event) { is CardIsPlayedEvent -> { val direction = @@ -83,5 +97,11 @@ private fun GameId.buildStateFromEvents(events: List): GameState = isStarted = true, ) } + + is PlayerWinEvent -> { + copy( + playerWins = playerWins + event.player, + ) + } } } diff --git a/src/main/kotlin/eventDemo/app/event/projection/GameStateRepository.kt b/src/main/kotlin/eventDemo/app/event/projection/GameStateRepository.kt new file mode 100644 index 0000000..a8daaaf --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/projection/GameStateRepository.kt @@ -0,0 +1,34 @@ +package eventDemo.app.event.projection + +import eventDemo.app.entity.GameId +import eventDemo.app.event.GameEventHandler +import eventDemo.app.event.GameEventStream +import java.util.concurrent.ConcurrentHashMap + +class GameStateRepository( + private val eventStream: GameEventStream, + eventHandler: GameEventHandler, +) { + private val projections: ConcurrentHashMap = ConcurrentHashMap() + + init { + eventHandler.registerProjectionBuilder { event -> + val projection = projections[event.gameId] + if (projection == null) { + event.gameId + .buildStateFromEventStream(eventStream) + .update() + } else { + projection + .apply(event) + .let { projections.put(it.gameId, it) } + } + } + } + + fun get(gameId: GameId): GameState = gameId.buildStateFromEventStream(eventStream) + + private fun GameState.update() { + projections[gameId] = this + } +} diff --git a/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt b/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt index 3a21fc6..fa79753 100644 --- a/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt +++ b/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt @@ -3,7 +3,6 @@ package eventDemo.app.eventListener import eventDemo.app.entity.Player import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStreamTo import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameStartedEvent @@ -11,11 +10,14 @@ import eventDemo.app.event.event.NewPlayerEvent import eventDemo.app.event.event.PlayerChoseColorEvent import eventDemo.app.event.event.PlayerHavePassEvent import eventDemo.app.event.event.PlayerReadyEvent +import eventDemo.app.event.event.PlayerWinEvent +import eventDemo.app.event.projection.buildStateFromEventStreamTo import eventDemo.app.notification.PlayerAsJoinTheGameNotification import eventDemo.app.notification.PlayerAsPlayACardNotification import eventDemo.app.notification.PlayerHavePassNotification import eventDemo.app.notification.PlayerWasChoseTheCardColorNotification import eventDemo.app.notification.PlayerWasReadyNotification +import eventDemo.app.notification.PlayerWinNotification import eventDemo.app.notification.TheGameWasStartedNotification import eventDemo.app.notification.WelcomeToTheGameNotification import eventDemo.app.notification.YourNewCardNotification @@ -102,7 +104,14 @@ class GameEventPlayerNotificationListener( null } } + + is PlayerWinEvent -> { + PlayerWinNotification( + player = event.player, + ) + } } + if (notification == null) { logger.atInfo { message = "Notification Ignore: $event" diff --git a/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt b/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt index 1f675fc..6613b11 100644 --- a/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt +++ b/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt @@ -1,14 +1,18 @@ package eventDemo.app.eventListener import eventDemo.app.event.GameEventBus +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStreamTo import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameStartedEvent +import eventDemo.app.event.event.PlayerWinEvent +import eventDemo.app.event.projection.GameState +import eventDemo.app.event.projection.buildStateFromEventStreamTo import io.github.oshai.kotlinlogging.KotlinLogging class GameEventReactionListener( private val eventBus: GameEventBus, + private val eventHandler: GameEventHandler, private val eventStream: GameEventStream, private val priority: Int = DEFAULT_PRIORITY, ) { @@ -21,22 +25,53 @@ class GameEventReactionListener( fun init() { eventBus.subscribe(priority) { event: GameEvent -> val state = event.buildStateFromEventStreamTo(eventStream) - if (state.isReady && !state.isStarted) { - val reactionEvent = - GameStartedEvent.new( - state.gameId, - state.players, + sendStartGameEvent(state, event) + sendWinnerEvent(state, event) + } + } + + private fun sendStartGameEvent( + state: GameState, + event: GameEvent, + ) { + if (state.isReady && !state.isStarted) { + val reactionEvent = + GameStartedEvent.new( + state.gameId, + state.players, + ) + logger.atInfo { + message = "Event Send on reaction of: $event" + payload = + mapOf( + "event" to event, + "reactionEvent" to reactionEvent, ) - logger.atInfo { - message = "Event Send on reaction of: $event" - payload = - mapOf( - "event" to event, - "reactionEvent" to reactionEvent, - ) - } - eventStream.publish(reactionEvent) } + eventHandler.handle(reactionEvent) + } + } + + private fun sendWinnerEvent( + state: GameState, + event: GameEvent, + ) { + val winner = state.playerHasNoCardLeft().firstOrNull() + if (winner != null) { + val reactionEvent = + PlayerWinEvent( + state.gameId, + winner, + ) + logger.atInfo { + message = "Event Send on reaction of: $event" + payload = + mapOf( + "event" to event, + "reactionEvent" to reactionEvent, + ) + } + eventStream.publish(reactionEvent) } } } diff --git a/src/main/kotlin/eventDemo/app/notification/PlayerWinNotification.kt b/src/main/kotlin/eventDemo/app/notification/PlayerWinNotification.kt new file mode 100644 index 0000000..c4f2415 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/PlayerWinNotification.kt @@ -0,0 +1,13 @@ +package eventDemo.app.notification + +import eventDemo.app.entity.Player +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PlayerWinNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val player: Player, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt b/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt index 4eda1d8..a361d80 100644 --- a/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt +++ b/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt @@ -1,10 +1,7 @@ package eventDemo.app.query import eventDemo.app.entity.GameId -import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStream -import eventDemo.app.event.event.CardIsPlayedEvent -import eventDemo.libs.event.readLastOf +import eventDemo.app.event.projection.GameStateRepository import eventDemo.shared.GameIdSerializer import io.ktor.http.HttpStatusCode import io.ktor.resources.Resource @@ -37,19 +34,21 @@ class Game( /** * API routes to read the game state. */ -fun Route.readTheGameState(eventStream: GameEventStream) { +fun Route.readTheGameState(gameStateRepository: GameStateRepository) { authenticate { // Read the last played card on the game. get { body -> - eventStream - .readLastOf(body.game.id) - ?.let { call.respond(it.card) } + gameStateRepository + .get(body.game.id) + .lastCard + ?.card + ?.let { call.respond(it) } ?: call.response.status(HttpStatusCode.BadRequest) } // Read the last played card on the game. get { body -> - val state = body.game.id.buildStateFromEventStream(eventStream) + val state = gameStateRepository.get(body.game.id) call.respond(state) } } diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt index 80ef6cc..2d1a700 100644 --- a/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt +++ b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt @@ -2,7 +2,9 @@ package eventDemo.configuration import eventDemo.app.command.GameCommandHandler import eventDemo.app.event.GameEventBus +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.GameEventStream +import eventDemo.app.event.projection.GameStateRepository import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.libs.event.EventBusInMemory import eventDemo.libs.event.EventStreamInMemory @@ -26,10 +28,16 @@ val appKoinModule = GameEventBus(EventBusInMemory()) } single { - GameEventStream(get(), EventStreamInMemory()) + GameEventStream(EventStreamInMemory()) } single { - GameCommandHandler(get()) + GameStateRepository(get(), get()) + } + single { + GameEventHandler(get(), get()) + } + single { + GameCommandHandler(get(), get()) } singleOf(::GameEventPlayerNotificationListener) diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt index 75d4348..2baa81c 100644 --- a/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt +++ b/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt @@ -5,6 +5,6 @@ import io.ktor.server.application.Application import org.koin.ktor.ext.get fun Application.configureGameListener() { - GameEventReactionListener(get(), get()) + GameEventReactionListener(get(), get(), get()) .init() } diff --git a/src/test/kotlin/eventDemo/app/event/projection/GameStateBuilderTest.kt b/src/test/kotlin/eventDemo/app/event/projection/GameStateBuilderTest.kt new file mode 100644 index 0000000..816f2d0 --- /dev/null +++ b/src/test/kotlin/eventDemo/app/event/projection/GameStateBuilderTest.kt @@ -0,0 +1,91 @@ +package eventDemo.app.event.projection + +import eventDemo.app.entity.Card +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player +import eventDemo.app.event.event.CardIsPlayedEvent +import eventDemo.app.event.event.GameStartedEvent +import eventDemo.app.event.event.NewPlayerEvent +import eventDemo.app.event.event.PlayerReadyEvent +import eventDemo.app.event.event.disableShuffleDeck +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class GameStateBuilderTest : + FunSpec({ + test("apply") { + disableShuffleDeck() + val gameId = GameId() + val player1 = Player(name = "Nikola") + val player2 = Player(name = "Einstein") + + GameState(gameId) + .run { + val event = NewPlayerEvent(gameId, player1) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + state.isReady shouldBeEqual false + state.isStarted shouldBeEqual false + } + }.run { + val event = NewPlayerEvent(gameId, player2) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + state.players shouldBeEqual setOf(player1, player2) + } + }.run { + val event = PlayerReadyEvent(gameId, player1) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + state.readyPlayers shouldBeEqual setOf(player1) + } + }.run { + val event = PlayerReadyEvent(gameId, player2) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + state.readyPlayers shouldBeEqual setOf(player1, player2) + state.isReady shouldBeEqual true + state.isStarted shouldBeEqual false + } + }.run { + val event = + GameStartedEvent.new( + gameId, + setOf(player1, player2), + shuffleIsDisabled = true, + ) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + state.isStarted shouldBeEqual true + assertIs(state.deck.stack.first()).let { + it.number shouldBeEqual 6 + it.color shouldBeEqual Card.Color.Red + } + } + }.run { + val playedCard = playableCards(player1)[0] + val event = CardIsPlayedEvent(gameId, playedCard, player1) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + assertNotNull(state.lastCard).card shouldBeEqual playedCard + assertIs(playedCard).let { + it.number shouldBeEqual 0 + it.color shouldBeEqual Card.Color.Red + } + } + }.run { + val playedCard = playableCards(player2)[0] + val event = CardIsPlayedEvent(gameId, playedCard, player2) + apply(event).also { state -> + state.gameId shouldBeEqual gameId + assertNotNull(state.lastCard).card shouldBeEqual playedCard + assertIs(playedCard).let { + it.number shouldBeEqual 7 + it.color shouldBeEqual Card.Color.Red + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt b/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt index d2a0634..d89f3a1 100644 --- a/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt +++ b/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt @@ -1,11 +1,15 @@ package eventDemo.app.query -import eventDemo.app.GameState import eventDemo.app.entity.Card import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventStream +import eventDemo.app.event.GameEventHandler import eventDemo.app.event.event.CardIsPlayedEvent +import eventDemo.app.event.event.GameStartedEvent +import eventDemo.app.event.event.NewPlayerEvent +import eventDemo.app.event.event.PlayerReadyEvent +import eventDemo.app.event.projection.GameState +import eventDemo.app.event.projection.GameStateRepository import eventDemo.configuration.configure import eventDemo.configuration.makeJwt import io.kotest.core.spec.style.FunSpec @@ -20,9 +24,13 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.testing.testApplication +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.koin.core.context.stopKoin import org.koin.ktor.ext.inject import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull class GameStateRouteTest : FunSpec({ @@ -51,30 +59,56 @@ class GameStateRouteTest : test("/game/{id}/card/last") { testApplication { - val id = GameId() - val card: Card = Card.NumericCard(1, Card.Color.Blue) - val player = Player(name = "Nikola") + val gameId = GameId() + val player1 = Player(name = "Nikola") + val player2 = Player(name = "Einstein") + var lastPlayedCard: Card? = null application { stopKoin() configure() - val eventStream by inject() - eventStream.publish( - CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player), - CardIsPlayedEvent(id, card, player), - // Other game - CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow), player), - ) + val eventHandler by inject() + val stateRepo by inject() + runBlocking { + eventHandler.handle( + NewPlayerEvent(gameId, player1), + NewPlayerEvent(gameId, player2), + PlayerReadyEvent(gameId, player1), + PlayerReadyEvent(gameId, player2), + GameStartedEvent.new( + gameId, + setOf(player1, player2), + shuffleIsDisabled = true, + ), + ) + delay(100) + lastPlayedCard = stateRepo.get(gameId).playableCards(player1).first() + assertNotNull(lastPlayedCard) + .let { assertIs(lastPlayedCard) } + .let { + it.number shouldBeEqual 0 + it.color shouldBeEqual Card.Color.Red + } + delay(100) + eventHandler.handle( + CardIsPlayedEvent( + gameId, + assertNotNull(lastPlayedCard), + player1, + ), + ) + delay(100) + } } httpClient() - .get("/game/$id/card/last") { - withAuth(player) + .get("/game/$gameId/card/last") { + withAuth(player1) accept(ContentType.Application.Json) }.apply { assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - assertEquals(card, call.body()) + assertEquals(assertNotNull(lastPlayedCard), call.body()) } } } diff --git a/src/test/kotlin/eventDemo/app/query/GameStateTest.kt b/src/test/kotlin/eventDemo/app/query/GameStateTest.kt index 3141a3b..ca3c081 100644 --- a/src/test/kotlin/eventDemo/app/query/GameStateTest.kt +++ b/src/test/kotlin/eventDemo/app/query/GameStateTest.kt @@ -1,6 +1,5 @@ package eventDemo.app.query -import eventDemo.app.GameState import eventDemo.app.command.GameCommandHandler import eventDemo.app.command.command.IWantToJoinTheGameCommand import eventDemo.app.command.command.IWantToPlayCardCommand @@ -8,8 +7,9 @@ import eventDemo.app.command.command.IamReadyToPlayCommand import eventDemo.app.entity.GameId import eventDemo.app.entity.Player import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStream import eventDemo.app.event.event.disableShuffleDeck +import eventDemo.app.event.projection.GameState +import eventDemo.app.event.projection.buildStateFromEventStream import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventReactionListener import eventDemo.app.notification.PlayerAsJoinTheGameNotification @@ -52,7 +52,7 @@ class GameStateTest : val commandHandler by inject() val playerNotificationListener by inject() val eventStream by inject() - GameEventReactionListener(get(), get()).init() + GameEventReactionListener(get(), get(), get()).init() playerNotificationListener.startListening(channelOut1, player1) playerNotificationListener.startListening(channelOut2, player2)