Create GameStateRepository

Use GameState.apply() to build Projection
Create GameEventHandler
Add PlayerWinEvent
This commit is contained in:
2025-03-09 03:43:31 +01:00
parent 3080e515d6
commit 19e425d684
22 changed files with 371 additions and 81 deletions

View File

@@ -1,15 +1,14 @@
package eventDemo.app.command
import eventDemo.app.GameState
import eventDemo.app.command.command.GameCommand
import eventDemo.app.command.command.ICantPlayCommand
import eventDemo.app.command.command.IWantToJoinTheGameCommand
import eventDemo.app.command.command.IWantToPlayCardCommand
import eventDemo.app.command.command.IamReadyToPlayCommand
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.projection.GameStateRepository
import eventDemo.app.notification.ErrorNotification
import eventDemo.shared.toFrame
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -24,7 +23,8 @@ import kotlinx.coroutines.channels.trySendBlocking
* This action can be executing an action and produce a new [GameEvent] after verification.
*/
class GameCommandHandler(
private val eventStream: GameEventStream,
private val eventHandler: GameEventHandler,
private val gameStateRepository: GameStateRepository,
) {
private val logger = KotlinLogging.logger { }
@@ -58,16 +58,14 @@ class GameCommandHandler(
nack()
}
val gameState = command.buildGameState()
val gameState = gameStateRepository.get(command.payload.gameId)
when (command) {
is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
}
}
}
private fun GameCommand.buildGameState(): GameState = payload.gameId.buildStateFromEventStream(eventStream)
}

View File

@@ -1,10 +1,10 @@
package eventDemo.app.command.command
import eventDemo.app.GameState
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.projection.GameState
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
@@ -26,13 +26,13 @@ data class ICantPlayCommand(
fun run(
state: GameState,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
eventHandler: GameEventHandler,
) {
val playableCards = state.playableCards(payload.player)
if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first()
eventStream.publish(
eventHandler.handle(
PlayerHavePassEvent(
gameId = payload.gameId,
player = payload.player,

View File

@@ -1,10 +1,10 @@
package eventDemo.app.command.command
import eventDemo.app.GameState
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.projection.GameState
import eventDemo.libs.command.CommandId
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable
@@ -27,11 +27,11 @@ data class IWantToJoinTheGameCommand(
fun run(
state: GameState,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
eventHandler: GameEventHandler,
) {
val logger = KotlinLogging.logger {}
if (!state.isStarted) {
eventStream.publish(
eventHandler.handle(
NewPlayerEvent(
payload.gameId,
payload.player,

View File

@@ -1,11 +1,11 @@
package eventDemo.app.command.command
import eventDemo.app.GameState
import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.projection.GameState
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
@@ -28,7 +28,7 @@ data class IWantToPlayCardCommand(
fun run(
state: GameState,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
eventHandler: GameEventHandler,
) {
if (!state.isStarted) {
playerErrorNotifier("The game is Not started")
@@ -36,7 +36,7 @@ data class IWantToPlayCardCommand(
}
if (state.canBePlayThisCard(payload.player, payload.card)) {
eventStream.publish(
eventHandler.handle(
CardIsPlayedEvent(
payload.gameId,
payload.card,

View File

@@ -1,10 +1,10 @@
package eventDemo.app.command.command
import eventDemo.app.GameState
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.projection.GameState
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
@@ -26,7 +26,7 @@ data class IamReadyToPlayCommand(
fun run(
state: GameState,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
eventHandler: GameEventHandler,
) {
val playerExist: Boolean = state.players.contains(payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player)
@@ -36,7 +36,7 @@ data class IamReadyToPlayCommand(
} else if (playerIsAlreadyReady) {
playerErrorNotifier("You are already ready")
} else {
eventStream.publish(
eventHandler.handle(
PlayerReadyEvent(
payload.gameId,
payload.player,

View File

@@ -47,6 +47,11 @@ data class Deck(
)
}
fun playerHasNoCardLeft(): List<Player.PlayerId> =
playersHands
.filter { (playerId, hand) -> hand.isEmpty() }
.map { (playerId, hand) -> playerId }
private fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toStack()

View File

@@ -0,0 +1,27 @@
package eventDemo.app.event
import eventDemo.app.event.event.GameEvent
/**
* A stream to publish and read the played card event.
*/
class GameEventHandler(
private val eventBus: GameEventBus,
private val eventStream: GameEventStream,
) {
private val projectionsBuilders: MutableList<(GameEvent) -> Unit> = mutableListOf()
fun registerProjectionBuilder(builder: GameProjectionBuilder) {
projectionsBuilders.add(builder)
}
fun handle(vararg events: GameEvent) {
events.forEach { event ->
eventStream.publish(event)
projectionsBuilders.forEach { it(event) }
eventBus.publish(event)
}
}
}
typealias GameProjectionBuilder = (GameEvent) -> Unit

View File

@@ -8,11 +8,9 @@ import eventDemo.libs.event.EventStream
* A stream to publish and read the played card event.
*/
class GameEventStream(
private val eventBus: GameEventBus,
private val eventStream: EventStream<GameEvent, GameId>,
) : EventStream<GameEvent, GameId> by eventStream {
override fun publish(event: GameEvent) {
eventStream.publish(event)
eventBus.publish(event)
}
}

View File

@@ -17,14 +17,15 @@ data class GameStartedEvent(
fun new(
id: GameId,
players: Set<Player>,
shuffleIsDisabled: Boolean = isDisabled,
): GameStartedEvent =
GameStartedEvent(
gameId = id,
firstPlayer = if (isDisabled) players.first() else players.random(),
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck =
Deck
.newWithoutPlayers()
.let { if (isDisabled) it else it.shuffle() }
.let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players)
.placeFirstCardOnDiscard(),
)

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 PlayerWinEvent(
override val gameId: GameId,
val player: Player,
) : GameEvent

View File

@@ -1,4 +1,4 @@
package eventDemo.app
package eventDemo.app.event.projection
import eventDemo.app.entity.Card
import eventDemo.app.entity.Deck
@@ -17,6 +17,7 @@ data class GameState(
val readyPlayers: Set<Player> = emptySet(),
val deck: Deck = Deck(players),
val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(),
) {
@Serializable
data class LastCard(
@@ -92,6 +93,11 @@ data class GameState(
?.filter { canBePlayThisCard(player, it) }
?: emptyList()
fun playerHasNoCardLeft(): List<Player> =
deck.playerHasNoCardLeft().map { playerId ->
players.find { it.id == playerId } ?: error("inconsistency detected between players")
}
fun canBePlayThisCard(
player: Player,
card: Card,

View File

@@ -1,8 +1,8 @@
package eventDemo.app.event
package eventDemo.app.event.projection
import eventDemo.app.GameState
import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent
@@ -10,19 +10,33 @@ import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerChoseColorEvent
import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.event.PlayerWinEvent
fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState =
buildStateFromEvents(
eventStream.readAll(this),
)
/**
* Build the state to the specific event
*/
fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState =
gameId.buildStateFromEvents(
eventStream.readAll(gameId).takeWhile { it != this } + this,
)
private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
events.fold(GameState(this)) { state: GameState, event: GameEvent ->
events.fold(GameState(this)) { state, event ->
state.apply(event)
}
fun List<GameEvent>.buildStateFromEvents(): GameState =
fold(GameState(this.first().gameId)) { state, event ->
state.apply(event)
}
fun GameState.apply(event: GameEvent): GameState =
let { state ->
when (event) {
is CardIsPlayedEvent -> {
val direction =
@@ -83,5 +97,11 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
isStarted = true,
)
}
is PlayerWinEvent -> {
copy(
playerWins = playerWins + event.player,
)
}
}
}

View File

@@ -0,0 +1,34 @@
package eventDemo.app.event.projection
import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream
import java.util.concurrent.ConcurrentHashMap
class GameStateRepository(
private val eventStream: GameEventStream,
eventHandler: GameEventHandler,
) {
private val projections: ConcurrentHashMap<GameId, GameState> = ConcurrentHashMap()
init {
eventHandler.registerProjectionBuilder { event ->
val projection = projections[event.gameId]
if (projection == null) {
event.gameId
.buildStateFromEventStream(eventStream)
.update()
} else {
projection
.apply(event)
.let { projections.put(it.gameId, it) }
}
}
}
fun get(gameId: GameId): GameState = gameId.buildStateFromEventStream(eventStream)
private fun GameState.update() {
projections[gameId] = this
}
}

View File

@@ -3,7 +3,6 @@ package eventDemo.app.eventListener
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStreamTo
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent
@@ -11,11 +10,14 @@ import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerChoseColorEvent
import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.event.PlayerWinEvent
import eventDemo.app.event.projection.buildStateFromEventStreamTo
import eventDemo.app.notification.PlayerAsJoinTheGameNotification
import eventDemo.app.notification.PlayerAsPlayACardNotification
import eventDemo.app.notification.PlayerHavePassNotification
import eventDemo.app.notification.PlayerWasChoseTheCardColorNotification
import eventDemo.app.notification.PlayerWasReadyNotification
import eventDemo.app.notification.PlayerWinNotification
import eventDemo.app.notification.TheGameWasStartedNotification
import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.app.notification.YourNewCardNotification
@@ -102,7 +104,14 @@ class GameEventPlayerNotificationListener(
null
}
}
is PlayerWinEvent -> {
PlayerWinNotification(
player = event.player,
)
}
}
if (notification == null) {
logger.atInfo {
message = "Notification Ignore: $event"

View File

@@ -1,14 +1,18 @@
package eventDemo.app.eventListener
import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStreamTo
import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent
import eventDemo.app.event.event.PlayerWinEvent
import eventDemo.app.event.projection.GameState
import eventDemo.app.event.projection.buildStateFromEventStreamTo
import io.github.oshai.kotlinlogging.KotlinLogging
class GameEventReactionListener(
private val eventBus: GameEventBus,
private val eventHandler: GameEventHandler,
private val eventStream: GameEventStream,
private val priority: Int = DEFAULT_PRIORITY,
) {
@@ -21,22 +25,53 @@ class GameEventReactionListener(
fun init() {
eventBus.subscribe(priority) { event: GameEvent ->
val state = event.buildStateFromEventStreamTo(eventStream)
if (state.isReady && !state.isStarted) {
val reactionEvent =
GameStartedEvent.new(
state.gameId,
state.players,
sendStartGameEvent(state, event)
sendWinnerEvent(state, event)
}
}
private fun sendStartGameEvent(
state: GameState,
event: GameEvent,
) {
if (state.isReady && !state.isStarted) {
val reactionEvent =
GameStartedEvent.new(
state.gameId,
state.players,
)
logger.atInfo {
message = "Event Send on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
logger.atInfo {
message = "Event Send on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
}
eventStream.publish(reactionEvent)
}
eventHandler.handle(reactionEvent)
}
}
private fun sendWinnerEvent(
state: GameState,
event: GameEvent,
) {
val winner = state.playerHasNoCardLeft().firstOrNull()
if (winner != null) {
val reactionEvent =
PlayerWinEvent(
state.gameId,
winner,
)
logger.atInfo {
message = "Event Send on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
}
eventStream.publish(reactionEvent)
}
}
}

View File

@@ -0,0 +1,13 @@
package eventDemo.app.notification
import eventDemo.app.entity.Player
import eventDemo.shared.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class PlayerWinNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val player: Player,
) : Notification

View File

@@ -1,10 +1,7 @@
package eventDemo.app.query
import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.libs.event.readLastOf
import eventDemo.app.event.projection.GameStateRepository
import eventDemo.shared.GameIdSerializer
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
@@ -37,19 +34,21 @@ class Game(
/**
* API routes to read the game state.
*/
fun Route.readTheGameState(eventStream: GameEventStream) {
fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
authenticate {
// Read the last played card on the game.
get<Game.Card> { body ->
eventStream
.readLastOf<CardIsPlayedEvent, _, _>(body.game.id)
?.let { call.respond(it.card) }
gameStateRepository
.get(body.game.id)
.lastCard
?.card
?.let { call.respond(it) }
?: call.response.status(HttpStatusCode.BadRequest)
}
// Read the last played card on the game.
get<Game.State> { body ->
val state = body.game.id.buildStateFromEventStream(eventStream)
val state = gameStateRepository.get(body.game.id)
call.respond(state)
}
}