diff --git a/build.gradle.kts b/build.gradle.kts index 6a7fd72..5781be9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,11 +54,15 @@ dependencies { implementation("io.ktor:ktor-server-netty-jvm") implementation("io.ktor:ktor-server-data-conversion") implementation("io.ktor:ktor-client-content-negotiation") + implementation("io.ktor:ktor-client-auth") implementation("ch.qos.logback:logback-classic:$logback_version") implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version") implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") + + testImplementation("io.kotest:kotest-extensions-koin:6.0.0.M2") testImplementation("io.ktor:ktor-server-tests-jvm") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") testImplementation("io.ktor:ktor-server-test-host-jvm:2.3.11") diff --git a/gradle.properties b/gradle.properties index aff205b..8aa2f04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,8 @@ -ktor_version=2.3.13 -#ktor_version=3.0.3 +ktor_version=3.0.3 kotlin_version=2.1.10 kotlin_serialization_version=1.8.0 logback_version=1.5.16 -koin_version=3.5.6 -# koin_version=4.0.2 +koin_version=4.0.2 kotlin_logging_version=5.1.0 #kotlin_logging_version=7.0.4 kotest_version=5.9.1 diff --git a/src/main/kotlin/eventDemo/app/GameState.kt b/src/main/kotlin/eventDemo/app/GameState.kt index 414cb85..b20be82 100644 --- a/src/main/kotlin/eventDemo/app/GameState.kt +++ b/src/main/kotlin/eventDemo/app/GameState.kt @@ -14,7 +14,7 @@ data class GameState( val lastCard: LastCard? = null, val lastColor: Card.Color? = null, val direction: Direction = Direction.CLOCKWISE, - val readyPlayers: List = emptyList(), + val readyPlayers: Set = emptySet(), val deck: Deck = Deck(players), val isStarted: Boolean = false, ) { @@ -51,6 +51,8 @@ data class GameState( } private val nextPlayerIndex: Int get() { + if (players.size == 0) return 0 + val y = if (direction == Direction.CLOCKWISE) { +1 @@ -61,7 +63,13 @@ data class GameState( return ((lastPlayerIndex ?: 0) + y) % players.size } - val nextPlayer: Player = players.elementAt(nextPlayerIndex) + val nextPlayer: Player? by lazy { + if (players.isEmpty()) { + null + } else { + players.elementAt(nextPlayerIndex) + } + } val Player.currentIndex: Int get() = players.indexOf(this) @@ -79,7 +87,8 @@ data class GameState( fun playableCards(player: Player): List = deck - .playersHands[player] + .playersHands + .getHand(player) ?.filter { canBePlayThisCard(player, it) } ?: emptyList() diff --git a/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt b/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt index 3c90ad9..36fc334 100644 --- a/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt +++ b/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt @@ -10,15 +10,13 @@ import eventDemo.app.entity.Player import eventDemo.app.event.GameEventStream import eventDemo.app.event.buildStateFromEventStream import eventDemo.app.event.event.GameEvent -import eventDemo.libs.command.CommandBlock +import eventDemo.app.notification.ErrorNotification +import eventDemo.shared.toFrame +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.websocket.Frame -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 /** * Listen [GameCommand] on [GameCommandStream], check the validity and execute an action. @@ -28,26 +26,32 @@ import kotlinx.coroutines.launch class GameCommandHandler( private val eventStream: GameEventStream, ) { + private val logger = KotlinLogging.logger { } + /** * Init the handler */ - @OptIn(DelicateCoroutinesApi::class) - fun handle( + suspend fun handle( player: Player, incoming: ReceiveChannel, outgoing: SendChannel, - ): Job { - val commandStream = GameCommandStream(incoming, outgoing) - val playerNotifier: (String) -> Unit = { outgoing.trySendBlocking(Frame.Text(it)) } - return GlobalScope.launch { - init(player, commandStream, playerNotifier) + ) { + val commandStream = GameCommandStream(incoming) + val playerErrorNotifier: (String) -> Unit = { + val notification = ErrorNotification(message = it) + logger.atInfo { + message = "Notification send ERROR: ${notification.message}" + payload = mapOf("notification" to notification) + } + outgoing.trySendBlocking(notification.toFrame()) } + return init(player, commandStream, playerErrorNotifier) } private suspend fun init( player: Player, commandStream: GameCommandStream, - playerNotifier: (String) -> Unit, + playerErrorNotifier: (String) -> Unit, ) { commandStream.process { command -> if (command.payload.player.id != player.id) { @@ -57,12 +61,12 @@ class GameCommandHandler( val gameState = command.buildGameState() when (command) { - is IWantToPlayCardCommand -> command.run(gameState, playerNotifier, eventStream) - is IamReadyToPlayCommand -> command.run(gameState, playerNotifier, eventStream) - is IWantToJoinTheGameCommand -> command.run(gameState, playerNotifier, eventStream) - is ICantPlayCommand -> command.run(gameState, playerNotifier, eventStream) + 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) } - } as CommandBlock + } } private fun GameCommand.buildGameState(): GameState = payload.gameId.buildStateFromEventStream(eventStream) diff --git a/src/main/kotlin/eventDemo/app/command/GameCommandRoute.kt b/src/main/kotlin/eventDemo/app/command/GameCommandRoute.kt index 768883b..aaf04e3 100644 --- a/src/main/kotlin/eventDemo/app/command/GameCommandRoute.kt +++ b/src/main/kotlin/eventDemo/app/command/GameCommandRoute.kt @@ -8,15 +8,22 @@ import io.ktor.server.auth.jwt.JWTPrincipal import io.ktor.server.auth.principal import io.ktor.server.routing.Route import io.ktor.server.websocket.webSocket +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +@DelicateCoroutinesApi fun Route.gameSocket( playerNotificationListener: GameEventPlayerNotificationListener, commandHandler: GameCommandHandler, ) { authenticate { webSocket("/game") { - commandHandler.handle(call.getPlayer(), incoming, outgoing) - playerNotificationListener.startListening(outgoing) + val currentPlayer = call.getPlayer() + GlobalScope.launch { + commandHandler.handle(currentPlayer, incoming, outgoing) + } + playerNotificationListener.startListening(outgoing, currentPlayer) } } } diff --git a/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt b/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt index 5fdf284..3ba5929 100644 --- a/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt +++ b/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt @@ -6,7 +6,6 @@ import eventDemo.libs.command.CommandStreamChannel import eventDemo.libs.command.CommandStreamInMemory import io.ktor.websocket.Frame import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel import kotlinx.serialization.json.Json /** @@ -19,10 +18,7 @@ class GameCommandStreamInMemory : CommandStreamInMemory() */ class GameCommandStream( incoming: ReceiveChannel, - outgoing: SendChannel, ) : CommandStream by CommandStreamChannel( incoming, - outgoing, - { Json.encodeToString(GameCommand.serializer(), it) }, { Json.decodeFromString(GameCommand.serializer(), it) }, ) diff --git a/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt b/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt index 7f115cd..f90d059 100644 --- a/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/ICantPlayCommand.kt @@ -6,14 +6,12 @@ import eventDemo.app.entity.Player import eventDemo.app.event.GameEventStream import eventDemo.app.event.event.PlayerHavePassEvent import eventDemo.libs.command.CommandId -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * A command to perform an action to play a new card */ @Serializable -@SerialName("Pass") data class ICantPlayCommand( override val payload: Payload, ) : GameCommand { @@ -27,19 +25,22 @@ data class ICantPlayCommand( fun run( state: GameState, - playerNotifier: (String) -> Unit, + playerErrorNotifier: (String) -> Unit, eventStream: GameEventStream, ) { val playableCards = state.playableCards(payload.player) if (playableCards.isEmpty()) { + val takenCard = state.deck.stack.first() + eventStream.publish( PlayerHavePassEvent( - payload.gameId, - payload.player, + gameId = payload.gameId, + player = payload.player, + takenCard = takenCard, ), ) } else { - playerNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}") + playerErrorNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}") } } } diff --git a/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt index e772a92..f96177a 100644 --- a/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IWantToJoinTheGameCommand.kt @@ -6,14 +6,13 @@ import eventDemo.app.entity.Player import eventDemo.app.event.GameEventStream import eventDemo.app.event.event.NewPlayerEvent import eventDemo.libs.command.CommandId -import kotlinx.serialization.SerialName +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.serialization.Serializable /** * A command to perform an action to play a new card */ @Serializable -@SerialName("JoinGame") data class IWantToJoinTheGameCommand( override val payload: Payload, ) : GameCommand { @@ -27,9 +26,10 @@ data class IWantToJoinTheGameCommand( fun run( state: GameState, - playerNotifier: (String) -> Unit, + playerErrorNotifier: (String) -> Unit, eventStream: GameEventStream, ) { + val logger = KotlinLogging.logger {} if (!state.isStarted) { eventStream.publish( NewPlayerEvent( @@ -38,7 +38,11 @@ data class IWantToJoinTheGameCommand( ), ) } else { - playerNotifier("The game is already started") + logger.atWarn { + message = "The game is already started" + payload = mapOf("player" to this@IWantToJoinTheGameCommand.payload.player) + } + playerErrorNotifier("The game is already started") } } } diff --git a/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt index 13264d8..0acaf23 100644 --- a/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IWantToPlayCardCommand.kt @@ -7,14 +7,12 @@ import eventDemo.app.entity.Player import eventDemo.app.event.GameEventStream import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.libs.command.CommandId -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * A command to perform an action to play a new card */ @Serializable -@SerialName("PlayCard") data class IWantToPlayCardCommand( override val payload: Payload, ) : GameCommand { @@ -29,11 +27,11 @@ data class IWantToPlayCardCommand( fun run( state: GameState, - playerNotifier: (String) -> Unit, + playerErrorNotifier: (String) -> Unit, eventStream: GameEventStream, ) { - if (!state.isReady) { - playerNotifier("The game is Not started") + if (!state.isStarted) { + playerErrorNotifier("The game is Not started") return } @@ -46,7 +44,7 @@ data class IWantToPlayCardCommand( ), ) } else { - playerNotifier("You cannot play this card") + playerErrorNotifier("You cannot play this card") } } } diff --git a/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt index 29de142..38f54db 100644 --- a/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt @@ -6,14 +6,12 @@ import eventDemo.app.entity.Player import eventDemo.app.event.GameEventStream import eventDemo.app.event.event.PlayerReadyEvent import eventDemo.libs.command.CommandId -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * A command to set as ready to play */ @Serializable -@SerialName("Ready") data class IamReadyToPlayCommand( override val payload: Payload, ) : GameCommand { @@ -27,13 +25,16 @@ data class IamReadyToPlayCommand( fun run( state: GameState, - playerNotifier: (String) -> Unit, + playerErrorNotifier: (String) -> Unit, eventStream: GameEventStream, ) { + val playerExist: Boolean = state.players.contains(payload.player) val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player) - if (playerIsAlreadyReady) { - playerNotifier("You are already ready") + if (!playerExist) { + playerErrorNotifier("You are not in the game") + } else if (playerIsAlreadyReady) { + playerErrorNotifier("You are already ready") } else { eventStream.publish( PlayerReadyEvent( diff --git a/src/main/kotlin/eventDemo/app/entity/Deck.kt b/src/main/kotlin/eventDemo/app/entity/Deck.kt index c50fcda..9d9fc51 100644 --- a/src/main/kotlin/eventDemo/app/entity/Deck.kt +++ b/src/main/kotlin/eventDemo/app/entity/Deck.kt @@ -11,6 +11,8 @@ data class Deck( constructor(players: Set) : this(playersHands = PlayersHands(players)) + fun shuffle(): Deck = copy(stack = stack.shuffle()) + fun placeFirstCardOnDiscard(): Deck { val takenCard = stack.first() return copy( @@ -21,16 +23,9 @@ data class Deck( fun takeOneCardFromStackTo(player: Player): Deck = takeOne().let { (deck, newPlayerCard) -> - val newHands = - deck.playersHands - .mapValues { (p, cards) -> - if (p == player) { - cards + newPlayerCard - } else { - cards - } - }.toPlayersHands() - deck.copy(playersHands = newHands) + deck.copy( + playersHands = deck.playersHands.addCard(player, newPlayerCard), + ) } fun putOneCardFromHand( @@ -40,7 +35,7 @@ data class Deck( run { // Validate parameters val playerHand = - playersHands[player] + playersHands.getHand(player) ?: error("No player on this game") if (playerHand.none { it == card }) { error("No card exist on the player hand") @@ -70,8 +65,7 @@ data class Deck( (1..2).map { Card.PassCard(color) } }.let { it + (1..4).map { Card.Plus4Card() } - }.shuffled() - .toStack() + }.toStack() .let { Deck(it) } } } @@ -100,6 +94,8 @@ value class Stack( operator fun plus(card: Card): Stack = cards.plus(card).toStack() operator fun minus(card: Card): Stack = cards.minus(card).toStack() + + fun shuffle(): Stack = shuffled().toStack() } fun List.toStack(): Stack = Stack(this.toSet()) diff --git a/src/main/kotlin/eventDemo/app/entity/GameId.kt b/src/main/kotlin/eventDemo/app/entity/GameId.kt index 3e7deb3..ff86bf5 100644 --- a/src/main/kotlin/eventDemo/app/entity/GameId.kt +++ b/src/main/kotlin/eventDemo/app/entity/GameId.kt @@ -12,4 +12,6 @@ import java.util.UUID @Serializable(with = GameIdSerializer::class) value class GameId( override val id: UUID = UUID.randomUUID(), -) : AggregateId +) : AggregateId { + override fun toString(): String = id.toString() +} diff --git a/src/main/kotlin/eventDemo/app/entity/Player.kt b/src/main/kotlin/eventDemo/app/entity/Player.kt index bc4ab21..370a84e 100644 --- a/src/main/kotlin/eventDemo/app/entity/Player.kt +++ b/src/main/kotlin/eventDemo/app/entity/Player.kt @@ -18,6 +18,7 @@ data class Player( PlayerId(UUID.fromString(id)), ) + @Serializable @JvmInline value class PlayerId( @Serializable(with = UUIDSerializer::class) diff --git a/src/main/kotlin/eventDemo/app/entity/PlayersHands.kt b/src/main/kotlin/eventDemo/app/entity/PlayersHands.kt index e3a6f73..ee70a24 100644 --- a/src/main/kotlin/eventDemo/app/entity/PlayersHands.kt +++ b/src/main/kotlin/eventDemo/app/entity/PlayersHands.kt @@ -5,28 +5,38 @@ import kotlinx.serialization.Serializable @Serializable @JvmInline value class PlayersHands( - private val map: Map> = emptyMap(), -) : Map> by map { - constructor(players: Set) : this(players.associateWith { emptyList() }.toPlayersHands()) + private val map: Map> = emptyMap(), +) : Map> by map { + constructor(players: Set) : + this(players.map { it.id }.associateWith { emptyList() }.toPlayersHands()) + + fun getHand(player: Player): List? = this[player.id] fun removeCard( player: Player, card: Card, ): PlayersHands = - mapValues { (p, cards) -> - if (p == player) { + mapValues { (playerId, cards) -> + if (playerId == player.id) { + if (!cards.contains(card)) error("The hand no contain the card") cards - card } else { cards } }.toPlayersHands() + fun addCard( + player: Player, + newCard: Card, + ): PlayersHands = addCards(player, listOf(newCard)) + fun addCards( player: Player, newCards: List, ): PlayersHands = mapValues { (p, cards) -> - if (p == player) { + if (p == player.id) { + if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card") cards + newCards } else { cards @@ -34,4 +44,4 @@ value class PlayersHands( }.toPlayersHands() } -fun Map>.toPlayersHands(): PlayersHands = PlayersHands(this) +fun Map>.toPlayersHands(): PlayersHands = PlayersHands(this) diff --git a/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt b/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt index 488a652..80235cd 100644 --- a/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt +++ b/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt @@ -10,13 +10,17 @@ 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.libs.event.EventStream -fun GameId.buildStateFromEventStream(eventStream: EventStream): GameState = +fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState = buildStateFromEvents( eventStream.readAll(this), ) +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 -> when (event) { @@ -37,12 +41,13 @@ private fun GameId.buildStateFromEvents(events: List): GameState = lastPlayer = event.player, direction = direction, lastColor = color, + lastCard = GameState.LastCard(event.card, event.player), deck = state.deck.putOneCardFromHand(event.player, event.card), ) } is NewPlayerEvent -> { - if (state.isReady) error("The game is already started") + if (state.isStarted) error("The game is already started") state.copy( players = state.players + event.player, @@ -56,6 +61,7 @@ private fun GameId.buildStateFromEvents(events: List): GameState = } is PlayerHavePassEvent -> { + if (event.takenCard != state.deck.stack.first()) error("taken card is not ot top of the stack") state.copy( lastPlayer = event.player, deck = state.deck.takeOneCardFromStackTo(event.player), @@ -70,7 +76,7 @@ private fun GameId.buildStateFromEvents(events: List): GameState = is GameStartedEvent -> { state.copy( - lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color, + lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.lastColor, lastCard = GameState.LastCard(event.deck.discard.first(), event.firstPlayer), lastPlayer = event.firstPlayer, deck = event.deck, diff --git a/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt index f1c1d40..9375846 100644 --- a/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt @@ -8,7 +8,7 @@ import eventDemo.app.entity.Player * An [GameEvent] to represent a played card. */ data class CardIsPlayedEvent( - override val id: GameId, + override val gameId: GameId, val card: Card, val player: Player, ) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/event/event/GameEvent.kt b/src/main/kotlin/eventDemo/app/event/event/GameEvent.kt index ee336c8..fb8c5c5 100644 --- a/src/main/kotlin/eventDemo/app/event/event/GameEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/GameEvent.kt @@ -9,5 +9,5 @@ import kotlinx.serialization.Serializable */ @Serializable sealed interface GameEvent : Event { - override val id: GameId + override val gameId: GameId } diff --git a/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt b/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt index cc3b37c..c71ee52 100644 --- a/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt @@ -9,7 +9,7 @@ import eventDemo.app.entity.initHands * This [GameEvent] is sent when all players are ready. */ data class GameStartedEvent( - override val id: GameId, + override val gameId: GameId, val firstPlayer: Player, val deck: Deck, ) : GameEvent { @@ -19,9 +19,20 @@ data class GameStartedEvent( players: Set, ): GameStartedEvent = GameStartedEvent( - id = id, - firstPlayer = players.random(), - deck = Deck.newWithoutPlayers().initHands(players).placeFirstCardOnDiscard(), + gameId = id, + firstPlayer = if (isDisabled) players.first() else players.random(), + deck = + Deck + .newWithoutPlayers() + .let { if (isDisabled) it else it.shuffle() } + .initHands(players) + .placeFirstCardOnDiscard(), ) } } + +private var isDisabled = false + +internal fun disableShuffleDeck() { + isDisabled = true +} diff --git a/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt index 5d861ca..566898b 100644 --- a/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt @@ -7,6 +7,6 @@ import eventDemo.app.entity.Player * An [GameEvent] to represent a new player joining the game. */ data class NewPlayerEvent( - override val id: GameId, + override val gameId: GameId, val player: Player, ) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/event/event/PlayerChoseColorEvent.kt b/src/main/kotlin/eventDemo/app/event/event/PlayerChoseColorEvent.kt index 25e0a7f..4218d24 100644 --- a/src/main/kotlin/eventDemo/app/event/event/PlayerChoseColorEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerChoseColorEvent.kt @@ -8,7 +8,7 @@ import eventDemo.app.entity.Player * This [GameEvent] is sent when a player chose a color. */ data class PlayerChoseColorEvent( - override val id: GameId, + override val gameId: GameId, val player: Player, val color: Card.Color, ) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/event/event/PlayerHavePassEvent.kt b/src/main/kotlin/eventDemo/app/event/event/PlayerHavePassEvent.kt index ec41705..f94747a 100644 --- a/src/main/kotlin/eventDemo/app/event/event/PlayerHavePassEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerHavePassEvent.kt @@ -1,5 +1,6 @@ package eventDemo.app.event.event +import eventDemo.app.entity.Card import eventDemo.app.entity.GameId import eventDemo.app.entity.Player @@ -7,6 +8,7 @@ import eventDemo.app.entity.Player * This [GameEvent] is sent when a player can play. */ data class PlayerHavePassEvent( - override val id: GameId, + override val gameId: GameId, val player: Player, + val takenCard: Card, ) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt index 5d25717..d3ac05b 100644 --- a/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt @@ -7,6 +7,6 @@ import eventDemo.app.entity.Player * This [GameEvent] is sent when a player is ready. */ data class PlayerReadyEvent( - override val id: GameId, + override val gameId: GameId, val player: Player, ) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt b/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt index 057fed1..3a21fc6 100644 --- a/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt +++ b/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt @@ -1,18 +1,126 @@ 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 +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.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.TheGameWasStartedNotification +import eventDemo.app.notification.WelcomeToTheGameNotification +import eventDemo.app.notification.YourNewCardNotification import eventDemo.shared.toFrame +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.websocket.Frame import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.trySendBlocking class GameEventPlayerNotificationListener( private val eventBus: GameEventBus, + private val eventStream: GameEventStream, ) { - fun startListening(outgoing: SendChannel) { + private val logger = KotlinLogging.logger {} + + fun startListening( + outgoing: SendChannel, + currentPlayer: Player, + ) { eventBus.subscribe { event: GameEvent -> - outgoing.trySendBlocking(event.toFrame()) + val currentState = event.buildStateFromEventStreamTo(eventStream) + val notification = + when (event) { + is NewPlayerEvent -> { + if (currentPlayer != event.player) { + PlayerAsJoinTheGameNotification( + player = event.player, + ) + } else { + WelcomeToTheGameNotification( + players = currentState.players, + ) + } + } + + is CardIsPlayedEvent -> { + if (currentPlayer != event.player) { + PlayerAsPlayACardNotification( + player = event.player, + card = event.card, + ) + } else { + null + } + } + + is GameStartedEvent -> { + TheGameWasStartedNotification( + hand = + event.deck.playersHands.getHand(currentPlayer) + ?: error("You are not in the game"), + ) + } + + is PlayerChoseColorEvent -> { + if (currentPlayer != event.player) { + PlayerWasChoseTheCardColorNotification( + player = event.player, + color = event.color, + ) + } else { + null + } + } + + is PlayerHavePassEvent -> { + if (currentPlayer == event.player) { + YourNewCardNotification( + card = event.takenCard, + ) + } else { + PlayerHavePassNotification( + player = event.player, + ) + } + } + + is PlayerReadyEvent -> { + if (currentPlayer != event.player) { + PlayerWasReadyNotification( + player = event.player, + ) + } else { + null + } + } + } + if (notification == null) { + logger.atInfo { + message = "Notification Ignore: $event" + payload = mapOf("event" to event) + } + } else if (currentState.players.contains(currentPlayer)) { + // Only notify players who have already joined the game. + outgoing.trySendBlocking(notification.toFrame()) + logger.atInfo { + message = "Notification SEND: $notification" + payload = mapOf("notification" to notification, "event" to event) + } + } else { + logger.atInfo { + message = "Notification SKIP: $notification" + payload = mapOf("notification" to notification, "event" to event) + } + } } } } diff --git a/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt b/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt index 0a15a53..1f675fc 100644 --- a/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt +++ b/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt @@ -2,24 +2,40 @@ package eventDemo.app.eventListener import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStream +import eventDemo.app.event.buildStateFromEventStreamTo import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameStartedEvent +import io.github.oshai.kotlinlogging.KotlinLogging class GameEventReactionListener( private val eventBus: GameEventBus, private val eventStream: GameEventStream, + private val priority: Int = DEFAULT_PRIORITY, ) { + companion object Config { + const val DEFAULT_PRIORITY = -1000 + } + + private val logger = KotlinLogging.logger { } + fun init() { - eventBus.subscribe { event: GameEvent -> - val state = event.id.buildStateFromEventStream(eventStream) - if (state.isReady) { - eventStream.publish( + eventBus.subscribe(priority) { event: GameEvent -> + val state = event.buildStateFromEventStreamTo(eventStream) + 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, + ) + } + eventStream.publish(reactionEvent) } } } diff --git a/src/main/kotlin/eventDemo/app/notification/ErrorNotification.kt b/src/main/kotlin/eventDemo/app/notification/ErrorNotification.kt new file mode 100644 index 0000000..e5642b4 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/ErrorNotification.kt @@ -0,0 +1,12 @@ +package eventDemo.app.notification + +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class ErrorNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val message: String, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/Notification.kt b/src/main/kotlin/eventDemo/app/notification/Notification.kt new file mode 100644 index 0000000..df1b71c --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/Notification.kt @@ -0,0 +1,11 @@ +package eventDemo.app.notification + +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +sealed interface Notification { + @Serializable(with = UUIDSerializer::class) + val id: UUID +} diff --git a/src/main/kotlin/eventDemo/app/notification/PlayerAsJoinTheGameNotification.kt b/src/main/kotlin/eventDemo/app/notification/PlayerAsJoinTheGameNotification.kt new file mode 100644 index 0000000..61498ee --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/PlayerAsJoinTheGameNotification.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 PlayerAsJoinTheGameNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val player: Player, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/PlayerAsPlayACardNotification.kt b/src/main/kotlin/eventDemo/app/notification/PlayerAsPlayACardNotification.kt new file mode 100644 index 0000000..cf75bea --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/PlayerAsPlayACardNotification.kt @@ -0,0 +1,15 @@ +package eventDemo.app.notification + +import eventDemo.app.entity.Card +import eventDemo.app.entity.Player +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PlayerAsPlayACardNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val player: Player, + val card: Card, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/PlayerHavePassNotification.kt b/src/main/kotlin/eventDemo/app/notification/PlayerHavePassNotification.kt new file mode 100644 index 0000000..69162dd --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/PlayerHavePassNotification.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 PlayerHavePassNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val player: Player, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/PlayerWasChoseTheCardColorNotification.kt b/src/main/kotlin/eventDemo/app/notification/PlayerWasChoseTheCardColorNotification.kt new file mode 100644 index 0000000..a413a73 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/PlayerWasChoseTheCardColorNotification.kt @@ -0,0 +1,15 @@ +package eventDemo.app.notification + +import eventDemo.app.entity.Card +import eventDemo.app.entity.Player +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PlayerWasChoseTheCardColorNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val player: Player, + val color: Card.Color, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/PlayerWasReadyNotification.kt b/src/main/kotlin/eventDemo/app/notification/PlayerWasReadyNotification.kt new file mode 100644 index 0000000..8d77f99 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/PlayerWasReadyNotification.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 PlayerWasReadyNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val player: Player, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/TheGameWasStartedNotification.kt b/src/main/kotlin/eventDemo/app/notification/TheGameWasStartedNotification.kt new file mode 100644 index 0000000..cd5e1ce --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/TheGameWasStartedNotification.kt @@ -0,0 +1,13 @@ +package eventDemo.app.notification + +import eventDemo.app.entity.Card +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class TheGameWasStartedNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val hand: List, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/WelcomeToTheGameNotification.kt b/src/main/kotlin/eventDemo/app/notification/WelcomeToTheGameNotification.kt new file mode 100644 index 0000000..a59a6b4 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/WelcomeToTheGameNotification.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 WelcomeToTheGameNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val players: Set, +) : Notification diff --git a/src/main/kotlin/eventDemo/app/notification/YourNewCardNotification.kt b/src/main/kotlin/eventDemo/app/notification/YourNewCardNotification.kt new file mode 100644 index 0000000..a35404d --- /dev/null +++ b/src/main/kotlin/eventDemo/app/notification/YourNewCardNotification.kt @@ -0,0 +1,13 @@ +package eventDemo.app.notification + +import eventDemo.app.entity.Card +import eventDemo.shared.UUIDSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class YourNewCardNotification( + @Serializable(with = UUIDSerializer::class) + override val id: UUID = UUID.randomUUID(), + val card: Card, +) : Notification diff --git a/src/main/kotlin/eventDemo/configuration/Configure.kt b/src/main/kotlin/eventDemo/configuration/Configure.kt index e8f6ffb..5a1a2a4 100644 --- a/src/main/kotlin/eventDemo/configuration/Configure.kt +++ b/src/main/kotlin/eventDemo/configuration/Configure.kt @@ -1,6 +1,5 @@ package eventDemo.configuration -import eventDemo.app.eventListener.GameEventReactionListener import io.ktor.server.application.Application import org.koin.ktor.ext.get @@ -17,6 +16,5 @@ fun Application.configure() { configureHttpRouting() declareHttpGameRoute() - GameEventReactionListener(get(), get()) - .init() + configureGameListener() } diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureAuth.kt b/src/main/kotlin/eventDemo/configuration/ConfigureAuth.kt index 9284020..dbda225 100644 --- a/src/main/kotlin/eventDemo/configuration/ConfigureAuth.kt +++ b/src/main/kotlin/eventDemo/configuration/ConfigureAuth.kt @@ -2,6 +2,7 @@ package eventDemo.configuration import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm +import eventDemo.app.entity.Player import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.call @@ -11,13 +12,15 @@ import io.ktor.server.auth.jwt.jwt import io.ktor.server.response.respond import io.ktor.server.routing.post import io.ktor.server.routing.routing +import kotlinx.serialization.json.Json import java.util.Date +// TODO: read the jwt property from the config file +private val jwtRealm = "Play card game" +private val jwtIssuer = "PlayCardGame" +private val jwtSecret = "secret" + fun Application.configureSecurity() { - // TODO: read the jwt property from the config file - val jwtRealm = "Play card game" - val jwtIssuer = "PlayCardGame" - val jwtSecret = "secret" authentication { jwt { realm = jwtRealm @@ -42,17 +45,19 @@ fun Application.configureSecurity() { routing { post("login/{username}") { - val username = call.parameters["username"] + val username = call.parameters["username"]!! + val player = Player(name = username) - val token = - JWT - .create() - .withIssuer(jwtIssuer) - .withClaim("username", username) - .withExpiresAt(Date(System.currentTimeMillis() + 60000)) - .sign(Algorithm.HMAC256(jwtSecret)) - - call.respond(hashMapOf("token" to token)) + call.respond(hashMapOf("token" to player.makeJwt())) } } } + +fun Player.makeJwt(): String = + JWT + .create() + .withIssuer(jwtIssuer) + .withClaim("username", name) + .withPayload(Json.encodeToString(this)) + .withExpiresAt(Date(System.currentTimeMillis() + 60000)) + .sign(Algorithm.HMAC256(jwtSecret)) diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt b/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt new file mode 100644 index 0000000..75d4348 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureGameListener.kt @@ -0,0 +1,10 @@ +package eventDemo.configuration + +import eventDemo.app.eventListener.GameEventReactionListener +import io.ktor.server.application.Application +import org.koin.ktor.ext.get + +fun Application.configureGameListener() { + GameEventReactionListener(get(), get()) + .init() +} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt b/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt index 3e9b7dc..a9724c6 100644 --- a/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt +++ b/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt @@ -1,5 +1,7 @@ package eventDemo.configuration +import eventDemo.app.entity.GameId +import eventDemo.shared.GameIdSerializer import eventDemo.shared.UUIDSerializer import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application @@ -16,6 +18,7 @@ fun Application.configureSerialization() { serializersModule = SerializersModule { contextual(UUID::class) { UUIDSerializer } + contextual(GameId::class) { GameIdSerializer } } }, ) diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt index 2c637ab..2301b4f 100644 --- a/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt +++ b/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt @@ -5,7 +5,9 @@ import eventDemo.app.command.gameSocket import eventDemo.app.eventListener.GameEventPlayerNotificationListener import io.ktor.server.application.Application import io.ktor.server.routing.routing +import kotlinx.coroutines.DelicateCoroutinesApi +@OptIn(DelicateCoroutinesApi::class) fun Application.declareWebSocketsGameRoute( playerNotificationListener: GameEventPlayerNotificationListener, commandHandler: GameCommandHandler, diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStream.kt b/src/main/kotlin/eventDemo/libs/command/CommandStream.kt index a3acdae..34bd9cf 100644 --- a/src/main/kotlin/eventDemo/libs/command/CommandStream.kt +++ b/src/main/kotlin/eventDemo/libs/command/CommandStream.kt @@ -3,7 +3,6 @@ package eventDemo.libs.command import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import kotlin.reflect.KClass /** * Represent a Command stream. @@ -11,24 +10,6 @@ import kotlin.reflect.KClass * The stream contains a list of all actions yet to be executed. */ interface CommandStream { - /** - * Send a new [Command] to the queue. - */ - fun send( - type: KClass, - command: C, - ) - - /** - * Send multiple [Command] to the queue. - */ - fun send( - type: KClass, - vararg commands: C, - ) { - commands.forEach { send(type, it) } - } - /** * A class to implement success/failed action. */ @@ -50,5 +31,3 @@ interface CommandStream { } } } - -suspend inline fun CommandStream.send(vararg command: C) = send(C::class, *command) diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt index cacce8a..821256a 100644 --- a/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt +++ b/src/main/kotlin/eventDemo/libs/command/CommandStreamChannel.kt @@ -4,45 +4,16 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.channels.onSuccess -import kotlinx.coroutines.channels.trySendBlocking -import kotlin.reflect.KClass /** * Manage [Command]'s with kotlin Channel */ class CommandStreamChannel( private val incoming: ReceiveChannel, - private val outgoing: SendChannel, - private val serializer: (C) -> String, private val deserializer: (String) -> C, ) : CommandStream { private val logger = KotlinLogging.logger {} - /** - * Send a new [Command] to the queue. - */ - override fun send( - type: KClass, - command: C, - ) { - outgoing - .trySendBlocking(Frame.Text(serializer(command))) - .onSuccess { - logger.atInfo { - message = "Command published: $command" - payload = mapOf("command" to command) - } - }.onFailure { - logger.atError { - message = "Command FAILED: $command" - payload = mapOf("command" to command) - } - } - } - override suspend fun process(action: CommandBlock) { // incoming.consumeEach { commandAsFrame -> // if (commandAsFrame is Frame.Text) { @@ -90,17 +61,15 @@ class CommandStreamChannel( private suspend fun markAsSuccess(command: C) { logger.atInfo { - message = "Compute command SUCCESS and it removed of the stack" + message = "Compute command SUCCESS: $command" payload = mapOf("command" to command) } -// outgoing.trySendBlocking(Frame.Text("Command executed successfully")) } private suspend fun markAsFailed(command: C) { logger.atWarn { - message = "Compute command FAILED" + message = "Compute command FAILED: $command" payload = mapOf("command" to command) } -// outgoing.trySendBlocking(Frame.Text("Command execution failed")) } } diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt index 2379745..7b2985e 100644 --- a/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt @@ -3,8 +3,6 @@ package eventDemo.libs.command import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.channels.trySendBlocking -import kotlin.reflect.KClass typealias CommandBlock = suspend CommandStream.ComputeStatus.(C) -> Unit @@ -20,20 +18,6 @@ abstract class CommandStreamInMemory : CommandStream { logger.atWarn { "${it::class.simpleName} command not send" } }) - /** - * Send a new [Command] to the queue. - */ - override fun send( - type: KClass, - command: C, - ) { - logger.atInfo { - message = "Command published: $command" - payload = mapOf("command" to command) - } - queue.trySendBlocking(command) - } - override suspend fun process(action: CommandBlock) { queue.consumeEach { command -> compute(command, action) @@ -71,14 +55,14 @@ abstract class CommandStreamInMemory : CommandStream { private fun markAsSuccess(command: C) { logger.atInfo { - message = "Compute command SUCCESS and it removed of the stack : $command" + message = "Compute command SUCCESS : $command" payload = mapOf("command" to command) } } private fun markAsFailed(command: C) { logger.atWarn { - message = "Compute command FAILED and it put it ot the top of the stack : $command" + message = "Compute command FAILED : $command" payload = mapOf("command" to command) } } diff --git a/src/main/kotlin/eventDemo/libs/event/Event.kt b/src/main/kotlin/eventDemo/libs/event/Event.kt index 749ea5e..33c06cb 100644 --- a/src/main/kotlin/eventDemo/libs/event/Event.kt +++ b/src/main/kotlin/eventDemo/libs/event/Event.kt @@ -15,5 +15,5 @@ interface AggregateId { * @see EventStream */ interface Event { - val id: ID + val gameId: ID } diff --git a/src/main/kotlin/eventDemo/libs/event/EventBus.kt b/src/main/kotlin/eventDemo/libs/event/EventBus.kt index 311a810..78ead60 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventBus.kt +++ b/src/main/kotlin/eventDemo/libs/event/EventBus.kt @@ -3,5 +3,11 @@ package eventDemo.libs.event interface EventBus, ID : AggregateId> { fun publish(event: E) - fun subscribe(block: (E) -> Unit) + /** + * @param priority The higher the priority, the more it will be called first + */ + fun subscribe( + priority: Int = 0, + block: (E) -> Unit, + ) } diff --git a/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt b/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt index 3723f6f..c6ceece 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt @@ -1,15 +1,20 @@ package eventDemo.libs.event class EventBusInMemory, ID : AggregateId> : EventBus { - private val subscribers: MutableList<(E) -> Unit> = mutableListOf() + private val subscribers: MutableList Unit>> = mutableListOf() override fun publish(event: E) { - subscribers.forEach { - it(event) - } + subscribers + .sortedByDescending { (priority, block) -> priority } + .forEach { (_, block) -> + block(event) + } } - override fun subscribe(block: (E) -> Unit) { - subscribers.add(block) + override fun subscribe( + priority: Int, + block: (E) -> Unit, + ) { + subscribers.add(priority to block) } } diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt index e304728..ac1754c 100644 --- a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt @@ -32,7 +32,7 @@ class EventStreamInMemory, ID : AggregateId> : EventStream ): R? = events .filterIsInstance(eventType.java) - .lastOrNull { it.id == aggregateId } + .lastOrNull { it.gameId == aggregateId } override fun readAll(aggregateId: ID): List = events } diff --git a/src/main/kotlin/eventDemo/shared/FrameConverter.kt b/src/main/kotlin/eventDemo/shared/FrameConverter.kt index 4e28665..0162c01 100644 --- a/src/main/kotlin/eventDemo/shared/FrameConverter.kt +++ b/src/main/kotlin/eventDemo/shared/FrameConverter.kt @@ -2,6 +2,7 @@ package eventDemo.shared import eventDemo.app.command.command.GameCommand import eventDemo.app.event.event.GameEvent +import eventDemo.app.notification.Notification import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.serialization.json.Json @@ -13,3 +14,11 @@ fun GameEvent.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameEvent.s fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText()) fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this)) + +fun Frame.toNotification(): Notification = + Json.decodeFromString( + Notification.serializer(), + (this as Frame.Text).readText(), + ) + +fun Notification.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(Notification.serializer(), this)) diff --git a/src/main/kotlin/eventDemo/shared/Serializer.kt b/src/main/kotlin/eventDemo/shared/Serializer.kt index f840c76..95e5b5a 100644 --- a/src/main/kotlin/eventDemo/shared/Serializer.kt +++ b/src/main/kotlin/eventDemo/shared/Serializer.kt @@ -34,7 +34,7 @@ object PlayerIdSerializer : KSerializer { encoder.encodeString(value.id.toString()) } - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PlayerId", PrimitiveKind.STRING) } object GameIdSerializer : KSerializer { diff --git a/src/test/kotlin/eventDemo/Helpers.kt b/src/test/kotlin/eventDemo/Helpers.kt index 984ea75..bbfc5b7 100644 --- a/src/test/kotlin/eventDemo/Helpers.kt +++ b/src/test/kotlin/eventDemo/Helpers.kt @@ -1,8 +1,14 @@ package eventDemo +import eventDemo.app.command.command.GameCommand import eventDemo.app.entity.Card import eventDemo.app.entity.Deck +import io.ktor.websocket.Frame +import kotlinx.coroutines.channels.SendChannel +import kotlinx.serialization.json.Json fun Deck.allCardCount(): Int = stack.size + discard.size + playersHands.values.flatten().size fun Deck.allCards(): Set = stack + discard + playersHands.values.flatten() + +suspend fun SendChannel.send(command: GameCommand) = send(Frame.Text(Json.encodeToString(command))) diff --git a/src/test/kotlin/eventDemo/app/entity/DeckTest.kt b/src/test/kotlin/eventDemo/app/entity/DeckTest.kt index 3db861e..01f8d76 100644 --- a/src/test/kotlin/eventDemo/app/entity/DeckTest.kt +++ b/src/test/kotlin/eventDemo/app/entity/DeckTest.kt @@ -55,9 +55,9 @@ class DeckTest : modifiedDeck.discard.size shouldBeExactly 0 modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1 modifiedDeck.playersHands.size shouldBeExactly playerNumbers - assertNotNull(modifiedDeck.playersHands[firstPlayer]).size shouldBeExactly 7 + 1 + assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 7 + 1 modifiedDeck.playersHands - .filterKeys { it != firstPlayer } + .filterKeys { it != firstPlayer.id } .forEach { (_, cards) -> cards.size shouldBeExactly 7 } modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber } @@ -70,16 +70,16 @@ class DeckTest : val firstPlayer = players.first() // When - val card = deck.playersHands[firstPlayer]!!.first() + val card = deck.playersHands.getHand(firstPlayer)!!.first() val modifiedDeck = deck.putOneCardFromHand(firstPlayer, card) // Then modifiedDeck.discard.size shouldBeExactly 1 modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) modifiedDeck.playersHands.size shouldBeExactly playerNumbers - assertNotNull(modifiedDeck.playersHands[firstPlayer]).size shouldBeExactly 6 + assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 6 modifiedDeck.playersHands - .filterKeys { it != firstPlayer } + .filterKeys { it != firstPlayer.id } .forEach { (_, cards) -> cards.size shouldBeExactly 7 } modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber } diff --git a/src/test/kotlin/eventDemo/app/entity/PlayerHandKtTest.kt b/src/test/kotlin/eventDemo/app/entity/PlayerHandKtTest.kt index 199cf75..1111596 100644 --- a/src/test/kotlin/eventDemo/app/entity/PlayerHandKtTest.kt +++ b/src/test/kotlin/eventDemo/app/entity/PlayerHandKtTest.kt @@ -17,8 +17,8 @@ class PlayerHandKtTest : // When val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card)) - assertNotNull(newHands[firstPlayer]).size shouldBeExactly 1 - assertNotNull(newHands[players.last()]).size shouldBeExactly 0 + assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 + assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 } test("removeCard") { @@ -35,7 +35,7 @@ class PlayerHandKtTest : // When val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1) - assertNotNull(newHands[firstPlayer]).size shouldBeExactly 1 - assertNotNull(newHands[players.last()]).size shouldBeExactly 0 + assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 + assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 } }) diff --git a/src/test/kotlin/eventDemo/app/query/CardTest.kt b/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt similarity index 58% rename from src/test/kotlin/eventDemo/app/query/CardTest.kt rename to src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt index 86ce9af..d2a0634 100644 --- a/src/test/kotlin/eventDemo/app/query/CardTest.kt +++ b/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt @@ -1,49 +1,50 @@ 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.event.CardIsPlayedEvent import eventDemo.configuration.configure +import eventDemo.configuration.makeJwt import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual import io.ktor.client.call.body +import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.accept import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.client.request.setBody +import io.ktor.client.request.header import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType.Application.Json +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType import io.ktor.server.testing.testApplication import org.koin.core.context.stopKoin -import org.koin.java.KoinJavaComponent.getKoin import org.koin.ktor.ext.inject import kotlin.test.assertEquals -class CardTest : +class GameStateRouteTest : FunSpec({ - test("/game/{id}/card") { + test("/game/{id}/state on empty game") { testApplication { + val id = GameId() + val player1 = Player(name = "Nikola") application { stopKoin() configure() } - val id = GameId() - val card: Card = Card.NumericCard(1, Card.Color.Blue) - val player = Player(name = "Nikola") httpClient() - .post("/game/$id/card") { - contentType(Json) - accept(Json) - setBody(card) + .get("/game/$id/state") { + withAuth(player1) + accept(ContentType.Application.Json) }.apply { assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - - val eventStream = getKoin().get() - assertEquals(CardIsPlayedEvent(id, card, player), eventStream.readLast(id)) + val state = call.body() + assertEquals(id, state.gameId) + state.players shouldHaveSize 0 + state.isStarted shouldBeEqual false } } } @@ -52,12 +53,13 @@ class CardTest : testApplication { val id = GameId() val card: Card = Card.NumericCard(1, Card.Color.Blue) + val player = Player(name = "Nikola") + application { stopKoin() configure() val eventStream by inject() - val player = Player(name = "Nikola") eventStream.publish( CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player), CardIsPlayedEvent(id, card, player), @@ -66,10 +68,18 @@ class CardTest : ) } - httpClient().get("/game/$id/card/last").apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - assertEquals(card, call.body()) - } + httpClient() + .get("/game/$id/card/last") { + withAuth(player) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + assertEquals(card, call.body()) + } } } }) + +private fun HttpRequestBuilder.withAuth(player: Player) { + header("Authorization", "Bearer ${player.makeJwt()}") +} diff --git a/src/test/kotlin/eventDemo/app/query/GameStateTest.kt b/src/test/kotlin/eventDemo/app/query/GameStateTest.kt new file mode 100644 index 0000000..1c2e012 --- /dev/null +++ b/src/test/kotlin/eventDemo/app/query/GameStateTest.kt @@ -0,0 +1,133 @@ +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 +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.eventListener.GameEventPlayerNotificationListener +import eventDemo.app.eventListener.GameEventReactionListener +import eventDemo.app.notification.PlayerAsJoinTheGameNotification +import eventDemo.app.notification.PlayerAsPlayACardNotification +import eventDemo.app.notification.PlayerWasReadyNotification +import eventDemo.app.notification.TheGameWasStartedNotification +import eventDemo.app.notification.WelcomeToTheGameNotification +import eventDemo.configuration.appKoinModule +import eventDemo.send +import eventDemo.shared.toNotification +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import io.ktor.websocket.Frame +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.dsl.koinApplication +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@DelicateCoroutinesApi +class GameStateTest : + FunSpec({ + test("Simulation of a game") { + disableShuffleDeck() + val id = GameId() + val player1 = Player(name = "Nikola") + val player2 = Player(name = "Einstein") + val channelIn1 = Channel(Channel.BUFFERED) + val channelIn2 = Channel(Channel.BUFFERED) + val channelOut1 = Channel(Channel.BUFFERED) + val channelOut2 = Channel(Channel.BUFFERED) + + koinApplication { modules(appKoinModule) }.koin.apply { + val commandHandler by inject() + val playerNotificationListener by inject() + val eventStream by inject() + GameEventReactionListener(get(), get()).init() + playerNotificationListener.startListening(channelOut1, player1) + playerNotificationListener.startListening(channelOut2, player2) + + GlobalScope.launch(Dispatchers.IO) { + commandHandler.handle(player1, channelIn1, channelOut1) + } + GlobalScope.launch(Dispatchers.IO) { + commandHandler.handle(player2, channelIn2, channelOut2) + } + + launch(Dispatchers.IO) { + channelIn1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1))) + } + launch(Dispatchers.IO) { + delay(200) + channelIn2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2))) + } + channelOut1.receive().toNotification().let { + assertIs(it).players shouldBeEqual setOf(player1) + } + + channelOut2.receive().toNotification().let { + assertIs(it).players shouldBeEqual setOf(player1, player2) + } + channelOut1.receive().toNotification().let { + assertIs(it).player shouldBeEqual player2 + } + + launch(Dispatchers.IO) { + channelIn1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1))) + } + launch(Dispatchers.IO) { + channelIn2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2))) + } + + channelOut1.receive().toNotification().let { + assertIs(it).player shouldBeEqual player2 + } + channelOut2.receive().toNotification().let { + assertIs(it).player shouldBeEqual player1 + } + + val player1Hand = + channelOut1.receive().toNotification().let { + assertIs(it).hand shouldHaveSize 7 + } + val player2Hand = + channelOut2.receive().toNotification().let { + assertIs(it).hand shouldHaveSize 7 + } + + launch { + channelIn1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first()))) + } + channelOut2.receive().toNotification().let { + assertIs(it).player shouldBeEqual player1 + assertIs(it).card shouldBeEqual player1Hand.first() + } + + launch { + channelIn2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first()))) + } + channelOut1.receive().toNotification().let { + assertIs(it).player shouldBeEqual player2 + assertIs(it).card shouldBeEqual player2Hand.first() + } + + val state = id.buildStateFromEventStream(eventStream) + + state.gameId shouldBeEqual id + assertTrue(state.isStarted) + state.players shouldBeEqual setOf(player1, player2) + state.readyPlayers shouldBeEqual setOf(player1, player2) + state.direction shouldBeEqual GameState.Direction.CLOCKWISE + assertNotNull(state.lastCard) shouldBeEqual GameState.LastCard(player2Hand.first(), player2) + } + } + }) diff --git a/src/test/kotlin/eventDemo/app/query/TestHttpClient.kt b/src/test/kotlin/eventDemo/app/query/TestHttpClient.kt index fa25907..d2bdaab 100644 --- a/src/test/kotlin/eventDemo/app/query/TestHttpClient.kt +++ b/src/test/kotlin/eventDemo/app/query/TestHttpClient.kt @@ -1,5 +1,6 @@ package eventDemo.app.query - +import eventDemo.app.entity.GameId +import eventDemo.shared.GameIdSerializer import eventDemo.shared.UUIDSerializer import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -17,6 +18,7 @@ fun ApplicationTestBuilder.httpClient(): HttpClient = serializersModule = SerializersModule { contextual(UUID::class) { UUIDSerializer } + contextual(GameId::class) { GameIdSerializer } } }, ) diff --git a/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt b/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt index d301c71..7f5ce9a 100644 --- a/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt +++ b/src/test/kotlin/eventDemo/libs/command/CommandStreamChannelTest.kt @@ -5,7 +5,10 @@ import io.ktor.websocket.Frame import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +@Serializable class CommandTest( override val id: CommandId, ) : Command @@ -15,16 +18,12 @@ class CommandStreamChannelTest : test("send and receive") { val command = CommandTest(CommandId()) - val command2 = CommandTest(CommandId()) - val command3 = CommandTest(CommandId()) val channel = Channel() val stream = CommandStreamChannel( incoming = channel, - outgoing = channel, - serializer = { it.id.toString() }, - deserializer = { CommandTest(CommandId(it)) }, + deserializer = { Json.decodeFromString(it) }, ) val spyCall: () -> Unit = mockk(relaxed = true) @@ -33,8 +32,7 @@ class CommandStreamChannelTest : println("In action ${it.id}") spyCall() } - stream.send(command, command2) - stream.send(command3) - verify(exactly = 3) { spyCall() } + channel.send(Frame.Text(Json.encodeToString(command))) + verify(exactly = 1) { spyCall() } } })