From bc35131bfc8911300495fd9cb512685a8b91fd4d Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Wed, 5 Mar 2025 00:07:41 +0100 Subject: [PATCH] add IamReadyToPlayCommand & refactoring --- src/main/kotlin/eventDemo/app/GameState.kt | 1 + .../kotlin/eventDemo/app/actions/HttpRoute.kt | 67 ---------------- .../app/{ => command}/GameCommandHandler.kt | 54 ++++++++----- .../eventDemo/app/command/GameCommandRoute.kt | 32 ++++++++ .../app/command/GameCommandStream.kt | 1 + .../app/command/command/GameCommand.kt | 17 ++++ .../command/command/IamReadyToPlayCommand.kt | 25 ++++++ .../IwantToPlayCardCommand.kt} | 24 +----- src/main/kotlin/eventDemo/app/entity/Card.kt | 2 +- .../eventDemo/app/{ => entity}/GameId.kt | 4 +- .../kotlin/eventDemo/app/entity/Player.kt | 12 +-- .../kotlin/eventDemo/app/entity/PlayerHand.kt | 11 +++ .../eventDemo/app/event/CardIsPlayedEvent.kt | 79 ------------------- .../eventDemo/app/event/GameEventBus.kt | 3 +- .../eventDemo/app/event/GameEventStream.kt | 3 +- .../eventDemo/app/event/GameStateBuilder.kt | 9 ++- .../app/event/event/CardIsPlayedEvent.kt | 14 ++++ .../eventDemo/app/event/event/GameEvent.kt | 13 +++ .../app/event/event/GameStartedEvent.kt | 26 ++++++ .../app/event/event/NewPlayerEvent.kt | 12 +++ .../app/event/event/PlayerChoseColorEvent.kt | 14 ++++ .../app/event/event/PlayerHavePassEvent.kt | 12 +++ .../app/event/event/PlayerReadyEvent.kt | 12 +++ .../GameEventPlayerNotificationListener.kt | 5 +- .../GameEventReactionListener.kt | 7 +- .../eventDemo/app/query/ReadTheGameState.kt | 56 +++++++++++++ .../eventDemo/configuration/Configure.kt | 8 +- .../{Security.kt => ConfigureAuth.kt} | 0 .../configuration/{Koin.kt => ConfigureDI.kt} | 0 .../{HTTP.kt => ConfigureHttp.kt} | 16 +++- .../configuration/ConfigureSerialization.kt | 23 ++++++ .../configuration/ConfigureWebSockets.kt | 17 ++++ .../ConfigureWebSocketsGameRoute.kt | 16 ++++ .../configuration/DeclareHttpRoutes.kt | 12 +++ .../kotlin/eventDemo/configuration/Routing.kt | 30 ------- .../kotlin/eventDemo/configuration/Sockets.kt | 50 ------------ .../kotlin/eventDemo/libs/command/Command.kt | 2 +- .../kotlin/eventDemo/shared/FrameConverter.kt | 4 +- .../Serialization.kt => shared/Serializer.kt} | 23 +----- .../app/{actions => query}/CardTest.kt | 6 +- .../app/{actions => query}/TestHttpClient.kt | 4 +- 41 files changed, 404 insertions(+), 322 deletions(-) delete mode 100644 src/main/kotlin/eventDemo/app/actions/HttpRoute.kt rename src/main/kotlin/eventDemo/app/{ => command}/GameCommandHandler.kt (51%) create mode 100644 src/main/kotlin/eventDemo/app/command/GameCommandRoute.kt create mode 100644 src/main/kotlin/eventDemo/app/command/command/GameCommand.kt create mode 100644 src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt rename src/main/kotlin/eventDemo/app/command/{PlayCardCommand.kt => command/IwantToPlayCardCommand.kt} (57%) rename src/main/kotlin/eventDemo/app/{ => entity}/GameId.kt (80%) create mode 100644 src/main/kotlin/eventDemo/app/entity/PlayerHand.kt delete mode 100644 src/main/kotlin/eventDemo/app/event/CardIsPlayedEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/GameEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/PlayerChoseColorEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/PlayerHavePassEvent.kt create mode 100644 src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt rename src/main/kotlin/eventDemo/app/{ => eventListener}/GameEventPlayerNotificationListener.kt (81%) rename src/main/kotlin/eventDemo/app/{ => eventListener}/GameEventReactionListener.kt (80%) create mode 100644 src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt rename src/main/kotlin/eventDemo/configuration/{Security.kt => ConfigureAuth.kt} (100%) rename src/main/kotlin/eventDemo/configuration/{Koin.kt => ConfigureDI.kt} (100%) rename src/main/kotlin/eventDemo/configuration/{HTTP.kt => ConfigureHttp.kt} (62%) create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureWebSockets.kt create mode 100644 src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt create mode 100644 src/main/kotlin/eventDemo/configuration/DeclareHttpRoutes.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/Routing.kt delete mode 100644 src/main/kotlin/eventDemo/configuration/Sockets.kt rename src/main/kotlin/eventDemo/{configuration/Serialization.kt => shared/Serializer.kt} (75%) rename src/test/kotlin/eventDemo/app/{actions => query}/CardTest.kt (95%) rename src/test/kotlin/eventDemo/app/{actions => query}/TestHttpClient.kt (90%) diff --git a/src/main/kotlin/eventDemo/app/GameState.kt b/src/main/kotlin/eventDemo/app/GameState.kt index e6b9feb..ccb904b 100644 --- a/src/main/kotlin/eventDemo/app/GameState.kt +++ b/src/main/kotlin/eventDemo/app/GameState.kt @@ -2,6 +2,7 @@ package eventDemo.app import eventDemo.app.entity.Card import eventDemo.app.entity.Deck +import eventDemo.app.entity.GameId import eventDemo.app.entity.Player import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/eventDemo/app/actions/HttpRoute.kt b/src/main/kotlin/eventDemo/app/actions/HttpRoute.kt deleted file mode 100644 index 959520a..0000000 --- a/src/main/kotlin/eventDemo/app/actions/HttpRoute.kt +++ /dev/null @@ -1,67 +0,0 @@ -package eventDemo.app.actions - -import eventDemo.app.GameId -import eventDemo.app.event.CardIsPlayedEvent -import eventDemo.app.event.GameEventStream -import eventDemo.app.event.buildStateFromEventStream -import eventDemo.configuration.GameIdSerializer -import eventDemo.libs.event.readLastOf -import io.ktor.http.HttpStatusCode -import io.ktor.resources.Resource -import io.ktor.server.application.call -import io.ktor.server.resources.get -import io.ktor.server.response.respond -import io.ktor.server.routing.Routing -import kotlinx.serialization.Serializable -import org.koin.ktor.ext.inject - -@Serializable -@Resource("/game/{id}") -class Game( - @Serializable(with = GameIdSerializer::class) - val id: GameId, -) { - @Serializable - @Resource("card/last") - class Card( - val game: Game, - ) - - @Serializable - @Resource("state") - class State( - val game: Game, - ) -} - -/** - * API route to read the last card played. - */ -fun Routing.readLastPlayedCard() { - val eventStream by inject() - - /* - * Read the last played card on the game. - */ - get { body -> - eventStream - .readLastOf(body.game.id) - ?.let { call.respond(it.card) } - ?: call.response.status(HttpStatusCode.BadRequest) - } -} - -/** - * API route to read the last card played. - */ -fun Routing.readGameState() { - val eventStream by inject() - - /* - * Read the last played card on the game. - */ - get { body -> - val state = body.game.id.buildStateFromEventStream(eventStream) - call.respond(state) - } -} diff --git a/src/main/kotlin/eventDemo/app/GameCommandHandler.kt b/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt similarity index 51% rename from src/main/kotlin/eventDemo/app/GameCommandHandler.kt rename to src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt index 505ac6d..4370589 100644 --- a/src/main/kotlin/eventDemo/app/GameCommandHandler.kt +++ b/src/main/kotlin/eventDemo/app/command/GameCommandHandler.kt @@ -1,14 +1,15 @@ -package eventDemo.app.actions +package eventDemo.app.command import eventDemo.app.GameState -import eventDemo.app.command.GameCommand -import eventDemo.app.command.GameCommandStream -import eventDemo.app.command.PlayCardCommand +import eventDemo.app.command.command.GameCommand +import eventDemo.app.command.command.IamReadyToPlayCommand +import eventDemo.app.command.command.IwantToPlayCardCommand import eventDemo.app.entity.Player -import eventDemo.app.event.CardIsPlayedEvent -import eventDemo.app.event.GameEvent import eventDemo.app.event.GameEventStream import eventDemo.app.event.buildStateFromEventStream +import eventDemo.app.event.event.CardIsPlayedEvent +import eventDemo.app.event.event.GameEvent +import eventDemo.app.event.event.PlayerReadyEvent import io.ktor.websocket.Frame import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,24 +36,22 @@ class GameCommandHandler( */ fun init(player: Player) { CoroutineScope(Dispatchers.IO).launch { - commandStream.process { - if (it.payload.player.id != player.id) { + commandStream.process { command -> + if (command.payload.player.id != player.id) { nack() } - when (it) { - is PlayCardCommand -> { - // Check the command can be executed - val canBeExecuted = - it.payload.gameId - .buildStateFromEventStream(eventStream) - .commandCardCanBeExecuted(it) - if (canBeExecuted) { + val state = command.buildState() + + when (command) { + is IwantToPlayCardCommand -> { + // Check the command can be executed + if (state.commandCardCanBeExecuted(command)) { eventStream.publish( CardIsPlayedEvent( - it.payload.gameId, - it.payload.card, - it.payload.player, + command.payload.gameId, + command.payload.card, + command.payload.player, ), ) } else { @@ -61,14 +60,29 @@ class GameCommandHandler( } } } + + is IamReadyToPlayCommand -> { + if (state.playerIsAlreadyReady(command.payload.player)) { + nack() + } else { + PlayerReadyEvent( + command.payload.gameId, + command.payload.player, + ) + } + } } } } } - private fun GameState.commandCardCanBeExecuted(command: PlayCardCommand): Boolean = + private fun GameState.playerIsAlreadyReady(player: Player): Boolean = readyPlayers.contains(player) + + private fun GameState.commandCardCanBeExecuted(command: IwantToPlayCardCommand): Boolean = canBePlayThisCard( command.payload.player, command.payload.card, ) + + private fun GameCommand.buildState(): 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 new file mode 100644 index 0000000..13ac544 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/command/GameCommandRoute.kt @@ -0,0 +1,32 @@ +package eventDemo.app.command + +import eventDemo.app.entity.Player +import eventDemo.app.event.GameEventBus +import eventDemo.app.event.GameEventStream +import eventDemo.app.eventListener.GameEventPlayerNotificationListener +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.authenticate +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 + +fun Route.gameSocket( + eventStream: GameEventStream, + eventBus: GameEventBus, +) { + authenticate { + webSocket("/game") { + GameCommandHandler(eventStream, incoming, outgoing).init(call.getPlayer()) + GameEventPlayerNotificationListener(eventBus, outgoing).init() + } + } +} + +private fun ApplicationCall.getPlayer() = + principal()!!.run { + Player( + id = payload.getClaim("playerid").asString(), + name = payload.getClaim("username").asString(), + ) + } diff --git a/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt b/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt index 56ea921..5fdf284 100644 --- a/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt +++ b/src/main/kotlin/eventDemo/app/command/GameCommandStream.kt @@ -1,5 +1,6 @@ package eventDemo.app.command +import eventDemo.app.command.command.GameCommand import eventDemo.libs.command.CommandStream import eventDemo.libs.command.CommandStreamChannel import eventDemo.libs.command.CommandStreamInMemory diff --git a/src/main/kotlin/eventDemo/app/command/command/GameCommand.kt b/src/main/kotlin/eventDemo/app/command/command/GameCommand.kt new file mode 100644 index 0000000..c6d251e --- /dev/null +++ b/src/main/kotlin/eventDemo/app/command/command/GameCommand.kt @@ -0,0 +1,17 @@ +package eventDemo.app.command.command + +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player +import eventDemo.libs.command.Command +import kotlinx.serialization.Serializable + +@Serializable +sealed interface GameCommand : Command { + val payload: Payload + + @Serializable + sealed interface Payload { + val gameId: GameId + val player: Player + } +} diff --git a/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt new file mode 100644 index 0000000..d18cafb --- /dev/null +++ b/src/main/kotlin/eventDemo/app/command/command/IamReadyToPlayCommand.kt @@ -0,0 +1,25 @@ +package eventDemo.app.command.command + +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player +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 { + override val name: String = "Ready" + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + override val gameId: GameId, + override val player: Player, + ) : GameCommand.Payload +} diff --git a/src/main/kotlin/eventDemo/app/command/PlayCardCommand.kt b/src/main/kotlin/eventDemo/app/command/command/IwantToPlayCardCommand.kt similarity index 57% rename from src/main/kotlin/eventDemo/app/command/PlayCardCommand.kt rename to src/main/kotlin/eventDemo/app/command/command/IwantToPlayCardCommand.kt index 153ff05..996de92 100644 --- a/src/main/kotlin/eventDemo/app/command/PlayCardCommand.kt +++ b/src/main/kotlin/eventDemo/app/command/command/IwantToPlayCardCommand.kt @@ -1,9 +1,8 @@ -package eventDemo.app.command +package eventDemo.app.command.command -import eventDemo.app.GameId import eventDemo.app.entity.Card +import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.libs.command.Command import eventDemo.libs.command.CommandId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -13,15 +12,9 @@ import kotlinx.serialization.Serializable */ @Serializable @SerialName("PlayCard") -data class PlayCardCommand( +data class IwantToPlayCardCommand( override val payload: Payload, ) : GameCommand { - constructor( - gameId: GameId, - player: Player, - card: Card, - ) : this(Payload(gameId, player, card)) - override val name: String = "PlayCard" override val id: CommandId = CommandId() @@ -32,14 +25,3 @@ data class PlayCardCommand( val card: Card, ) : GameCommand.Payload } - -@Serializable -sealed interface GameCommand : Command { - val payload: Payload - - @Serializable - sealed interface Payload { - val gameId: GameId - val player: Player - } -} diff --git a/src/main/kotlin/eventDemo/app/entity/Card.kt b/src/main/kotlin/eventDemo/app/entity/Card.kt index 03238b8..f51213c 100644 --- a/src/main/kotlin/eventDemo/app/entity/Card.kt +++ b/src/main/kotlin/eventDemo/app/entity/Card.kt @@ -1,6 +1,6 @@ package eventDemo.app.entity -import eventDemo.configuration.UUIDSerializer +import eventDemo.shared.UUIDSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/app/GameId.kt b/src/main/kotlin/eventDemo/app/entity/GameId.kt similarity index 80% rename from src/main/kotlin/eventDemo/app/GameId.kt rename to src/main/kotlin/eventDemo/app/entity/GameId.kt index c913b03..3e7deb3 100644 --- a/src/main/kotlin/eventDemo/app/GameId.kt +++ b/src/main/kotlin/eventDemo/app/entity/GameId.kt @@ -1,7 +1,7 @@ -package eventDemo.app +package eventDemo.app.entity -import eventDemo.configuration.GameIdSerializer import eventDemo.libs.event.AggregateId +import eventDemo.shared.GameIdSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/app/entity/Player.kt b/src/main/kotlin/eventDemo/app/entity/Player.kt index 662a419..bc4ab21 100644 --- a/src/main/kotlin/eventDemo/app/entity/Player.kt +++ b/src/main/kotlin/eventDemo/app/entity/Player.kt @@ -1,8 +1,8 @@ package eventDemo.app.entity -import eventDemo.configuration.PlayerIdSerializer -import eventDemo.configuration.UUIDSerializer import eventDemo.libs.event.AggregateId +import eventDemo.shared.PlayerIdSerializer +import eventDemo.shared.UUIDSerializer import io.ktor.server.auth.Principal import kotlinx.serialization.Serializable import java.util.UUID @@ -26,11 +26,3 @@ data class Player( override fun toString(): String = id.toString() } } - -@Serializable -data class PlayerHand( - val player: Player, - val cards: List = emptyList(), -) { - val count = lazy { cards.count() } -} diff --git a/src/main/kotlin/eventDemo/app/entity/PlayerHand.kt b/src/main/kotlin/eventDemo/app/entity/PlayerHand.kt new file mode 100644 index 0000000..4fea5cc --- /dev/null +++ b/src/main/kotlin/eventDemo/app/entity/PlayerHand.kt @@ -0,0 +1,11 @@ +package eventDemo.app.entity + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerHand( + val player: Player, + val cards: List = emptyList(), +) { + val count = lazy { cards.count() } +} diff --git a/src/main/kotlin/eventDemo/app/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/app/event/CardIsPlayedEvent.kt deleted file mode 100644 index 0662a79..0000000 --- a/src/main/kotlin/eventDemo/app/event/CardIsPlayedEvent.kt +++ /dev/null @@ -1,79 +0,0 @@ -package eventDemo.app.event - -import eventDemo.app.GameId -import eventDemo.app.entity.Card -import eventDemo.app.entity.Deck -import eventDemo.app.entity.Player -import eventDemo.libs.event.Event -import kotlinx.serialization.Serializable - -/** - * An [Event] of a Game. - */ -@Serializable -sealed interface GameEvent : Event { - override val id: GameId -} - -/** - * An [Event] to represent a played card. - */ -data class CardIsPlayedEvent( - override val id: GameId, - val card: Card, - val player: Player, -) : GameEvent - -/** - * An [Event] to represent a new player joining the game. - */ -data class NewPlayerEvent( - override val id: GameId, - val player: Player, -) : GameEvent - -/** - * This [Event] is sent when a player is ready. - */ -data class PlayerReadyEvent( - override val id: GameId, - val player: Player, -) : GameEvent - -/** - * This [Event] is sent when a player is ready. - */ -data class GameStartedEvent( - override val id: GameId, - val firstPlayer: Player, - val deck: Deck, -) : GameEvent { - companion object { - fun new( - id: GameId, - players: Set, - ): GameStartedEvent = - GameStartedEvent( - id = id, - firstPlayer = players.random(), - deck = Deck.initHands(players).putOneCardOnDiscard(), - ) - } -} - -/** - * This [Event] is sent when a player can play. - */ -data class PlayerHavePassEvent( - override val id: GameId, - val player: Player, -) : GameEvent - -/** - * This [Event] is sent when a player chose a color. - */ -data class PlayerChoseColorEvent( - override val id: GameId, - val player: Player, - val color: Card.Color, -) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/event/GameEventBus.kt b/src/main/kotlin/eventDemo/app/event/GameEventBus.kt index 5cb7749..2dbb9d4 100644 --- a/src/main/kotlin/eventDemo/app/event/GameEventBus.kt +++ b/src/main/kotlin/eventDemo/app/event/GameEventBus.kt @@ -1,6 +1,7 @@ package eventDemo.app.event -import eventDemo.app.GameId +import eventDemo.app.entity.GameId +import eventDemo.app.event.event.GameEvent import eventDemo.libs.event.EventBus class GameEventBus( diff --git a/src/main/kotlin/eventDemo/app/event/GameEventStream.kt b/src/main/kotlin/eventDemo/app/event/GameEventStream.kt index 09389ac..2c7d31f 100644 --- a/src/main/kotlin/eventDemo/app/event/GameEventStream.kt +++ b/src/main/kotlin/eventDemo/app/event/GameEventStream.kt @@ -1,6 +1,7 @@ package eventDemo.app.event -import eventDemo.app.GameId +import eventDemo.app.entity.GameId +import eventDemo.app.event.event.GameEvent import eventDemo.libs.event.EventBus import eventDemo.libs.event.EventStream diff --git a/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt b/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt index a35ddc4..d616bda 100644 --- a/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt +++ b/src/main/kotlin/eventDemo/app/event/GameStateBuilder.kt @@ -1,8 +1,15 @@ package eventDemo.app.event -import eventDemo.app.GameId import eventDemo.app.GameState import eventDemo.app.entity.Card +import eventDemo.app.entity.GameId +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.libs.event.EventStream fun GameId.buildStateFromEventStream(eventStream: EventStream): GameState = diff --git a/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt new file mode 100644 index 0000000..f1c1d40 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/CardIsPlayedEvent.kt @@ -0,0 +1,14 @@ +package eventDemo.app.event.event + +import eventDemo.app.entity.Card +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player + +/** + * An [GameEvent] to represent a played card. + */ +data class CardIsPlayedEvent( + override val id: 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 new file mode 100644 index 0000000..ee336c8 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/GameEvent.kt @@ -0,0 +1,13 @@ +package eventDemo.app.event.event + +import eventDemo.app.entity.GameId +import eventDemo.libs.event.Event +import kotlinx.serialization.Serializable + +/** + * An [Event] of a Game. + */ +@Serializable +sealed interface GameEvent : Event { + override val id: GameId +} diff --git a/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt b/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt new file mode 100644 index 0000000..9a6beb8 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/GameStartedEvent.kt @@ -0,0 +1,26 @@ +package eventDemo.app.event.event + +import eventDemo.app.entity.Deck +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player + +/** + * This [GameEvent] is sent when all players is ready. + */ +data class GameStartedEvent( + override val id: GameId, + val firstPlayer: Player, + val deck: Deck, +) : GameEvent { + companion object { + fun new( + id: GameId, + players: Set, + ): GameStartedEvent = + GameStartedEvent( + id = id, + firstPlayer = players.random(), + deck = Deck.initHands(players).putOneCardOnDiscard(), + ) + } +} diff --git a/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt b/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt new file mode 100644 index 0000000..5d861ca --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/NewPlayerEvent.kt @@ -0,0 +1,12 @@ +package eventDemo.app.event.event + +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player + +/** + * An [GameEvent] to represent a new player joining the game. + */ +data class NewPlayerEvent( + override val id: 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 new file mode 100644 index 0000000..25e0a7f --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerChoseColorEvent.kt @@ -0,0 +1,14 @@ +package eventDemo.app.event.event + +import eventDemo.app.entity.Card +import eventDemo.app.entity.GameId +import eventDemo.app.entity.Player + +/** + * This [GameEvent] is sent when a player chose a color. + */ +data class PlayerChoseColorEvent( + override val id: 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 new file mode 100644 index 0000000..ec41705 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerHavePassEvent.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 can play. + */ +data class PlayerHavePassEvent( + override val id: GameId, + val player: Player, +) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt b/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.kt new file mode 100644 index 0000000..5d25717 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/event/event/PlayerReadyEvent.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 PlayerReadyEvent( + override val id: GameId, + val player: Player, +) : GameEvent diff --git a/src/main/kotlin/eventDemo/app/GameEventPlayerNotificationListener.kt b/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt similarity index 81% rename from src/main/kotlin/eventDemo/app/GameEventPlayerNotificationListener.kt rename to src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt index 61088f8..8a604a5 100644 --- a/src/main/kotlin/eventDemo/app/GameEventPlayerNotificationListener.kt +++ b/src/main/kotlin/eventDemo/app/eventListener/GameEventPlayerNotificationListener.kt @@ -1,6 +1,7 @@ -package eventDemo.app +package eventDemo.app.eventListener -import eventDemo.app.event.GameEvent +import eventDemo.app.entity.GameId +import eventDemo.app.event.event.GameEvent import eventDemo.libs.event.EventBus import eventDemo.shared.toFrame import io.ktor.websocket.Frame diff --git a/src/main/kotlin/eventDemo/app/GameEventReactionListener.kt b/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt similarity index 80% rename from src/main/kotlin/eventDemo/app/GameEventReactionListener.kt rename to src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt index 3d3677b..7de3dc8 100644 --- a/src/main/kotlin/eventDemo/app/GameEventReactionListener.kt +++ b/src/main/kotlin/eventDemo/app/eventListener/GameEventReactionListener.kt @@ -1,8 +1,9 @@ -package eventDemo.app +package eventDemo.app.eventListener -import eventDemo.app.event.GameEvent -import eventDemo.app.event.GameStartedEvent +import eventDemo.app.entity.GameId import eventDemo.app.event.buildStateFromEventStream +import eventDemo.app.event.event.GameEvent +import eventDemo.app.event.event.GameStartedEvent import eventDemo.libs.event.EventBus import eventDemo.libs.event.EventStream diff --git a/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt b/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt new file mode 100644 index 0000000..4eda1d8 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/query/ReadTheGameState.kt @@ -0,0 +1,56 @@ +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.shared.GameIdSerializer +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.auth.authenticate +import io.ktor.server.resources.get +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/game/{id}") +class Game( + @Serializable(with = GameIdSerializer::class) + val id: GameId, +) { + @Serializable + @Resource("card/last") + class Card( + val game: Game, + ) + + @Serializable + @Resource("state") + class State( + val game: Game, + ) +} + +/** + * API routes to read the game state. + */ +fun Route.readTheGameState(eventStream: GameEventStream) { + authenticate { + // Read the last played card on the game. + get { body -> + eventStream + .readLastOf(body.game.id) + ?.let { call.respond(it.card) } + ?: call.response.status(HttpStatusCode.BadRequest) + } + + // Read the last played card on the game. + get { body -> + val state = body.game.id.buildStateFromEventStream(eventStream) + call.respond(state) + } + } +} diff --git a/src/main/kotlin/eventDemo/configuration/Configure.kt b/src/main/kotlin/eventDemo/configuration/Configure.kt index 09316c5..e8f6ffb 100644 --- a/src/main/kotlin/eventDemo/configuration/Configure.kt +++ b/src/main/kotlin/eventDemo/configuration/Configure.kt @@ -1,6 +1,6 @@ package eventDemo.configuration -import eventDemo.app.GameEventReactionListener +import eventDemo.app.eventListener.GameEventReactionListener import io.ktor.server.application.Application import org.koin.ktor.ext.get @@ -11,11 +11,11 @@ fun Application.configure() { configureSerialization() - configureSockets() - configureWebSocketsGameRoute(get(), get()) + configureWebSockets() + declareWebSocketsGameRoute(get(), get()) - configureHttp() configureHttpRouting() + declareHttpGameRoute() GameEventReactionListener(get(), get()) .init() diff --git a/src/main/kotlin/eventDemo/configuration/Security.kt b/src/main/kotlin/eventDemo/configuration/ConfigureAuth.kt similarity index 100% rename from src/main/kotlin/eventDemo/configuration/Security.kt rename to src/main/kotlin/eventDemo/configuration/ConfigureAuth.kt diff --git a/src/main/kotlin/eventDemo/configuration/Koin.kt b/src/main/kotlin/eventDemo/configuration/ConfigureDI.kt similarity index 100% rename from src/main/kotlin/eventDemo/configuration/Koin.kt rename to src/main/kotlin/eventDemo/configuration/ConfigureDI.kt diff --git a/src/main/kotlin/eventDemo/configuration/HTTP.kt b/src/main/kotlin/eventDemo/configuration/ConfigureHttp.kt similarity index 62% rename from src/main/kotlin/eventDemo/configuration/HTTP.kt rename to src/main/kotlin/eventDemo/configuration/ConfigureHttp.kt index fc22252..367089d 100644 --- a/src/main/kotlin/eventDemo/configuration/HTTP.kt +++ b/src/main/kotlin/eventDemo/configuration/ConfigureHttp.kt @@ -5,9 +5,13 @@ import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.install +import io.ktor.server.plugins.autohead.AutoHeadResponse import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.resources.Resources +import io.ktor.server.response.respondText -fun Application.configureHttp() { +fun Application.configureHttpRouting() { install(CORS) { allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Put) @@ -18,6 +22,16 @@ fun Application.configureHttp() { allowHeader("MyCustomHeader") anyHost() // @TODO: Don't do this in production if possible. Try to limit it. } + install(AutoHeadResponse) + install(Resources) + install(StatusPages) { + exception { call, cause -> + call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest) + } + exception { call, cause -> + call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) + } + } } class BadRequestException( diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt b/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt new file mode 100644 index 0000000..3e9b7dc --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureSerialization.kt @@ -0,0 +1,23 @@ +package eventDemo.configuration + +import eventDemo.shared.UUIDSerializer +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import java.util.UUID + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json( + Json { + serializersModule = + SerializersModule { + contextual(UUID::class) { UUIDSerializer } + } + }, + ) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureWebSockets.kt b/src/main/kotlin/eventDemo/configuration/ConfigureWebSockets.kt new file mode 100644 index 0000000..8dbd209 --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureWebSockets.kt @@ -0,0 +1,17 @@ +package eventDemo.configuration + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.pingPeriod +import io.ktor.server.websocket.timeout +import java.time.Duration + +fun Application.configureWebSockets() { + install(WebSockets) { + pingPeriod = Duration.ofSeconds(15) + timeout = Duration.ofSeconds(15) + maxFrameSize = Long.MAX_VALUE + masking = false + } +} diff --git a/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt b/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt new file mode 100644 index 0000000..18ab2fd --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/ConfigureWebSocketsGameRoute.kt @@ -0,0 +1,16 @@ +package eventDemo.configuration + +import eventDemo.app.command.gameSocket +import eventDemo.app.event.GameEventBus +import eventDemo.app.event.GameEventStream +import io.ktor.server.application.Application +import io.ktor.server.routing.routing + +fun Application.declareWebSocketsGameRoute( + eventStream: GameEventStream, + eventBus: GameEventBus, +) { + routing { + gameSocket(eventStream, eventBus) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/DeclareHttpRoutes.kt b/src/main/kotlin/eventDemo/configuration/DeclareHttpRoutes.kt new file mode 100644 index 0000000..b8454cf --- /dev/null +++ b/src/main/kotlin/eventDemo/configuration/DeclareHttpRoutes.kt @@ -0,0 +1,12 @@ +package eventDemo.configuration + +import eventDemo.app.query.readTheGameState +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import org.koin.ktor.ext.get + +fun Application.declareHttpGameRoute() { + routing { + readTheGameState(get()) + } +} diff --git a/src/main/kotlin/eventDemo/configuration/Routing.kt b/src/main/kotlin/eventDemo/configuration/Routing.kt deleted file mode 100644 index cf9dcc4..0000000 --- a/src/main/kotlin/eventDemo/configuration/Routing.kt +++ /dev/null @@ -1,30 +0,0 @@ -package eventDemo.configuration - -import eventDemo.app.actions.readGameState -import eventDemo.app.actions.readLastPlayedCard -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.plugins.autohead.AutoHeadResponse -import io.ktor.server.plugins.statuspages.StatusPages -import io.ktor.server.resources.Resources -import io.ktor.server.response.respondText -import io.ktor.server.routing.routing - -fun Application.configureHttpRouting() { - install(AutoHeadResponse) - install(Resources) - install(StatusPages) { - exception { call, cause -> - call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest) - } - exception { call, cause -> - call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) - } - } - - routing { - readLastPlayedCard() - readGameState() - } -} diff --git a/src/main/kotlin/eventDemo/configuration/Sockets.kt b/src/main/kotlin/eventDemo/configuration/Sockets.kt deleted file mode 100644 index b04f630..0000000 --- a/src/main/kotlin/eventDemo/configuration/Sockets.kt +++ /dev/null @@ -1,50 +0,0 @@ -package eventDemo.configuration - -import eventDemo.app.GameEventPlayerNotificationListener -import eventDemo.app.actions.GameCommandHandler -import eventDemo.app.entity.Player -import eventDemo.app.event.GameEventBus -import eventDemo.app.event.GameEventStream -import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.install -import io.ktor.server.auth.authenticate -import io.ktor.server.auth.jwt.JWTPrincipal -import io.ktor.server.auth.principal -import io.ktor.server.routing.routing -import io.ktor.server.websocket.WebSockets -import io.ktor.server.websocket.pingPeriod -import io.ktor.server.websocket.timeout -import io.ktor.server.websocket.webSocket -import java.time.Duration - -fun Application.configureSockets() { - install(WebSockets) { - pingPeriod = Duration.ofSeconds(15) - timeout = Duration.ofSeconds(15) - maxFrameSize = Long.MAX_VALUE - masking = false - } -} - -fun Application.configureWebSocketsGameRoute( - eventStream: GameEventStream, - eventBus: GameEventBus, -) { - routing { - authenticate { - webSocket("/game") { - GameCommandHandler(eventStream, incoming, outgoing).init(call.getPlayer()) - GameEventPlayerNotificationListener(eventBus, outgoing).init() - } - } - } -} - -fun ApplicationCall.getPlayer() = - principal()!!.run { - Player( - id = payload.getClaim("playerid").asString(), - name = payload.getClaim("username").asString(), - ) - } diff --git a/src/main/kotlin/eventDemo/libs/command/Command.kt b/src/main/kotlin/eventDemo/libs/command/Command.kt index 999b0c0..5971c2d 100644 --- a/src/main/kotlin/eventDemo/libs/command/Command.kt +++ b/src/main/kotlin/eventDemo/libs/command/Command.kt @@ -1,6 +1,6 @@ package eventDemo.libs.command -import eventDemo.configuration.CommandIdSerializer +import eventDemo.shared.CommandIdSerializer import kotlinx.serialization.Serializable import java.util.UUID diff --git a/src/main/kotlin/eventDemo/shared/FrameConverter.kt b/src/main/kotlin/eventDemo/shared/FrameConverter.kt index 7974afa..4e28665 100644 --- a/src/main/kotlin/eventDemo/shared/FrameConverter.kt +++ b/src/main/kotlin/eventDemo/shared/FrameConverter.kt @@ -1,7 +1,7 @@ package eventDemo.shared -import eventDemo.app.command.GameCommand -import eventDemo.app.event.GameEvent +import eventDemo.app.command.command.GameCommand +import eventDemo.app.event.event.GameEvent import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.serialization.json.Json diff --git a/src/main/kotlin/eventDemo/configuration/Serialization.kt b/src/main/kotlin/eventDemo/shared/Serializer.kt similarity index 75% rename from src/main/kotlin/eventDemo/configuration/Serialization.kt rename to src/main/kotlin/eventDemo/shared/Serializer.kt index f01be79..f840c76 100644 --- a/src/main/kotlin/eventDemo/configuration/Serialization.kt +++ b/src/main/kotlin/eventDemo/shared/Serializer.kt @@ -1,35 +1,16 @@ -package eventDemo.configuration +package eventDemo.shared -import eventDemo.app.GameId +import eventDemo.app.entity.GameId import eventDemo.app.entity.Player.PlayerId import eventDemo.libs.command.CommandId -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule import java.util.UUID -fun Application.configureSerialization() { - install(ContentNegotiation) { - json( - Json { - serializersModule = - SerializersModule { - contextual(UUID::class) { UUIDSerializer } - } - }, - ) - } -} - object CommandIdSerializer : KSerializer { override fun deserialize(decoder: Decoder): CommandId = CommandId(decoder.decodeString()) diff --git a/src/test/kotlin/eventDemo/app/actions/CardTest.kt b/src/test/kotlin/eventDemo/app/query/CardTest.kt similarity index 95% rename from src/test/kotlin/eventDemo/app/actions/CardTest.kt rename to src/test/kotlin/eventDemo/app/query/CardTest.kt index e6c4e4a..86ce9af 100644 --- a/src/test/kotlin/eventDemo/app/actions/CardTest.kt +++ b/src/test/kotlin/eventDemo/app/query/CardTest.kt @@ -1,10 +1,10 @@ -package eventDemo.app.actions +package eventDemo.app.query -import eventDemo.app.GameId import eventDemo.app.entity.Card +import eventDemo.app.entity.GameId import eventDemo.app.entity.Player -import eventDemo.app.event.CardIsPlayedEvent import eventDemo.app.event.GameEventStream +import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.configuration.configure import io.kotest.core.spec.style.FunSpec import io.ktor.client.call.body diff --git a/src/test/kotlin/eventDemo/app/actions/TestHttpClient.kt b/src/test/kotlin/eventDemo/app/query/TestHttpClient.kt similarity index 90% rename from src/test/kotlin/eventDemo/app/actions/TestHttpClient.kt rename to src/test/kotlin/eventDemo/app/query/TestHttpClient.kt index 06859e4..fa25907 100644 --- a/src/test/kotlin/eventDemo/app/actions/TestHttpClient.kt +++ b/src/test/kotlin/eventDemo/app/query/TestHttpClient.kt @@ -1,6 +1,6 @@ -package eventDemo.app.actions +package eventDemo.app.query -import eventDemo.configuration.UUIDSerializer +import eventDemo.shared.UUIDSerializer import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json