add IamReadyToPlayCommand & refactoring

This commit is contained in:
2025-03-05 00:07:41 +01:00
parent 06443d7efa
commit bc35131bfc
41 changed files with 404 additions and 322 deletions

View File

@@ -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

View File

@@ -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<GameEventStream>()
/*
* Read the last played card on the game.
*/
get<Game.Card> { body ->
eventStream
.readLastOf<CardIsPlayedEvent, _, _>(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<GameEventStream>()
/*
* Read the last played card on the game.
*/
get<Game.State> { body ->
val state = body.game.id.buildStateFromEventStream(eventStream)
call.respond(state)
}
}

View File

@@ -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)
}

View File

@@ -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<JWTPrincipal>()!!.run {
Player(
id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(),
)
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<Card> = emptyList(),
) {
val count = lazy { cards.count() }
}

View File

@@ -0,0 +1,11 @@
package eventDemo.app.entity
import kotlinx.serialization.Serializable
@Serializable
data class PlayerHand(
val player: Player,
val cards: List<Card> = emptyList(),
) {
val count = lazy { cards.count() }
}

View File

@@ -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<GameId> {
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<Player>,
): 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

View File

@@ -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(

View File

@@ -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

View File

@@ -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<GameEvent, GameId>): GameState =

View File

@@ -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

View File

@@ -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<GameId> {
override val id: GameId
}

View File

@@ -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<Player>,
): GameStartedEvent =
GameStartedEvent(
id = id,
firstPlayer = players.random(),
deck = Deck.initHands(players).putOneCardOnDiscard(),
)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<Game.Card> { body ->
eventStream
.readLastOf<CardIsPlayedEvent, _, _>(body.game.id)
?.let { call.respond(it.card) }
?: call.response.status(HttpStatusCode.BadRequest)
}
// Read the last played card on the game.
get<Game.State> { body ->
val state = body.game.id.buildStateFromEventStream(eventStream)
call.respond(state)
}
}
}