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 package eventDemo.app.command
import eventDemo.app.GameState
import eventDemo.app.command.command.GameCommand import eventDemo.app.command.command.GameCommand
import eventDemo.app.command.command.ICantPlayCommand import eventDemo.app.command.command.ICantPlayCommand
import eventDemo.app.command.command.IWantToJoinTheGameCommand import eventDemo.app.command.command.IWantToJoinTheGameCommand
import eventDemo.app.command.command.IWantToPlayCardCommand import eventDemo.app.command.command.IWantToPlayCardCommand
import eventDemo.app.command.command.IamReadyToPlayCommand import eventDemo.app.command.command.IamReadyToPlayCommand
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.projection.GameStateRepository
import eventDemo.app.notification.ErrorNotification import eventDemo.app.notification.ErrorNotification
import eventDemo.shared.toFrame import eventDemo.shared.toFrame
import io.github.oshai.kotlinlogging.KotlinLogging 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. * This action can be executing an action and produce a new [GameEvent] after verification.
*/ */
class GameCommandHandler( class GameCommandHandler(
private val eventStream: GameEventStream, private val eventHandler: GameEventHandler,
private val gameStateRepository: GameStateRepository,
) { ) {
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
@@ -58,16 +58,14 @@ class GameCommandHandler(
nack() nack()
} }
val gameState = command.buildGameState() val gameState = gameStateRepository.get(command.payload.gameId)
when (command) { when (command) {
is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventStream) is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream) is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventStream) is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventHandler)
is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream) 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 package eventDemo.app.command.command
import eventDemo.app.GameState
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player 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.event.PlayerHavePassEvent
import eventDemo.app.event.projection.GameState
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -26,13 +26,13 @@ data class ICantPlayCommand(
fun run( fun run(
state: GameState, state: GameState,
playerErrorNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream, eventHandler: GameEventHandler,
) { ) {
val playableCards = state.playableCards(payload.player) val playableCards = state.playableCards(payload.player)
if (playableCards.isEmpty()) { if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first() val takenCard = state.deck.stack.first()
eventStream.publish( eventHandler.handle(
PlayerHavePassEvent( PlayerHavePassEvent(
gameId = payload.gameId, gameId = payload.gameId,
player = payload.player, player = payload.player,

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
package eventDemo.app.command.command package eventDemo.app.command.command
import eventDemo.app.GameState
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player 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.event.PlayerReadyEvent
import eventDemo.app.event.projection.GameState
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -26,7 +26,7 @@ data class IamReadyToPlayCommand(
fun run( fun run(
state: GameState, state: GameState,
playerErrorNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream, eventHandler: GameEventHandler,
) { ) {
val playerExist: Boolean = state.players.contains(payload.player) val playerExist: Boolean = state.players.contains(payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player) val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player)
@@ -36,7 +36,7 @@ data class IamReadyToPlayCommand(
} else if (playerIsAlreadyReady) { } else if (playerIsAlreadyReady) {
playerErrorNotifier("You are already ready") playerErrorNotifier("You are already ready")
} else { } else {
eventStream.publish( eventHandler.handle(
PlayerReadyEvent( PlayerReadyEvent(
payload.gameId, payload.gameId,
payload.player, 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>> { private fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n) val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toStack() 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. * A stream to publish and read the played card event.
*/ */
class GameEventStream( class GameEventStream(
private val eventBus: GameEventBus,
private val eventStream: EventStream<GameEvent, GameId>, private val eventStream: EventStream<GameEvent, GameId>,
) : EventStream<GameEvent, GameId> by eventStream { ) : EventStream<GameEvent, GameId> by eventStream {
override fun publish(event: GameEvent) { override fun publish(event: GameEvent) {
eventStream.publish(event) eventStream.publish(event)
eventBus.publish(event)
} }
} }

View File

@@ -17,14 +17,15 @@ data class GameStartedEvent(
fun new( fun new(
id: GameId, id: GameId,
players: Set<Player>, players: Set<Player>,
shuffleIsDisabled: Boolean = isDisabled,
): GameStartedEvent = ): GameStartedEvent =
GameStartedEvent( GameStartedEvent(
gameId = id, gameId = id,
firstPlayer = if (isDisabled) players.first() else players.random(), firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck = deck =
Deck Deck
.newWithoutPlayers() .newWithoutPlayers()
.let { if (isDisabled) it else it.shuffle() } .let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players) .initHands(players)
.placeFirstCardOnDiscard(), .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.Card
import eventDemo.app.entity.Deck import eventDemo.app.entity.Deck
@@ -17,6 +17,7 @@ data class GameState(
val readyPlayers: Set<Player> = emptySet(), val readyPlayers: Set<Player> = emptySet(),
val deck: Deck = Deck(players), val deck: Deck = Deck(players),
val isStarted: Boolean = false, val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(),
) { ) {
@Serializable @Serializable
data class LastCard( data class LastCard(
@@ -92,6 +93,11 @@ data class GameState(
?.filter { canBePlayThisCard(player, it) } ?.filter { canBePlayThisCard(player, it) }
?: emptyList() ?: emptyList()
fun playerHasNoCardLeft(): List<Player> =
deck.playerHasNoCardLeft().map { playerId ->
players.find { it.id == playerId } ?: error("inconsistency detected between players")
}
fun canBePlayThisCard( fun canBePlayThisCard(
player: Player, player: Player,
card: Card, 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.Card
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent 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.PlayerChoseColorEvent
import eventDemo.app.event.event.PlayerHavePassEvent import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.event.PlayerReadyEvent import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.event.PlayerWinEvent
fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState = fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState =
buildStateFromEvents( buildStateFromEvents(
eventStream.readAll(this), eventStream.readAll(this),
) )
/**
* Build the state to the specific event
*/
fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState = fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState =
gameId.buildStateFromEvents( gameId.buildStateFromEvents(
eventStream.readAll(gameId).takeWhile { it != this } + this, eventStream.readAll(gameId).takeWhile { it != this } + this,
) )
private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState = 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) { when (event) {
is CardIsPlayedEvent -> { is CardIsPlayedEvent -> {
val direction = val direction =
@@ -83,5 +97,11 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
isStarted = true, 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.entity.Player
import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStreamTo
import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent 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.PlayerChoseColorEvent
import eventDemo.app.event.event.PlayerHavePassEvent import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.event.PlayerReadyEvent 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.PlayerAsJoinTheGameNotification
import eventDemo.app.notification.PlayerAsPlayACardNotification import eventDemo.app.notification.PlayerAsPlayACardNotification
import eventDemo.app.notification.PlayerHavePassNotification import eventDemo.app.notification.PlayerHavePassNotification
import eventDemo.app.notification.PlayerWasChoseTheCardColorNotification import eventDemo.app.notification.PlayerWasChoseTheCardColorNotification
import eventDemo.app.notification.PlayerWasReadyNotification import eventDemo.app.notification.PlayerWasReadyNotification
import eventDemo.app.notification.PlayerWinNotification
import eventDemo.app.notification.TheGameWasStartedNotification import eventDemo.app.notification.TheGameWasStartedNotification
import eventDemo.app.notification.WelcomeToTheGameNotification import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.app.notification.YourNewCardNotification import eventDemo.app.notification.YourNewCardNotification
@@ -102,7 +104,14 @@ class GameEventPlayerNotificationListener(
null null
} }
} }
is PlayerWinEvent -> {
PlayerWinNotification(
player = event.player,
)
} }
}
if (notification == null) { if (notification == null) {
logger.atInfo { logger.atInfo {
message = "Notification Ignore: $event" message = "Notification Ignore: $event"

View File

@@ -1,14 +1,18 @@
package eventDemo.app.eventListener package eventDemo.app.eventListener
import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStreamTo
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent 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 import io.github.oshai.kotlinlogging.KotlinLogging
class GameEventReactionListener( class GameEventReactionListener(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val eventHandler: GameEventHandler,
private val eventStream: GameEventStream, private val eventStream: GameEventStream,
private val priority: Int = DEFAULT_PRIORITY, private val priority: Int = DEFAULT_PRIORITY,
) { ) {
@@ -21,6 +25,15 @@ class GameEventReactionListener(
fun init() { fun init() {
eventBus.subscribe(priority) { event: GameEvent -> eventBus.subscribe(priority) { event: GameEvent ->
val state = event.buildStateFromEventStreamTo(eventStream) val state = event.buildStateFromEventStreamTo(eventStream)
sendStartGameEvent(state, event)
sendWinnerEvent(state, event)
}
}
private fun sendStartGameEvent(
state: GameState,
event: GameEvent,
) {
if (state.isReady && !state.isStarted) { if (state.isReady && !state.isStarted) {
val reactionEvent = val reactionEvent =
GameStartedEvent.new( GameStartedEvent.new(
@@ -35,8 +48,30 @@ class GameEventReactionListener(
"reactionEvent" to reactionEvent, "reactionEvent" to 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) 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 package eventDemo.app.query
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventStream import eventDemo.app.event.projection.GameStateRepository
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.libs.event.readLastOf
import eventDemo.shared.GameIdSerializer import eventDemo.shared.GameIdSerializer
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource import io.ktor.resources.Resource
@@ -37,19 +34,21 @@ class Game(
/** /**
* API routes to read the game state. * API routes to read the game state.
*/ */
fun Route.readTheGameState(eventStream: GameEventStream) { fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
authenticate { authenticate {
// Read the last played card on the game. // Read the last played card on the game.
get<Game.Card> { body -> get<Game.Card> { body ->
eventStream gameStateRepository
.readLastOf<CardIsPlayedEvent, _, _>(body.game.id) .get(body.game.id)
?.let { call.respond(it.card) } .lastCard
?.card
?.let { call.respond(it) }
?: call.response.status(HttpStatusCode.BadRequest) ?: call.response.status(HttpStatusCode.BadRequest)
} }
// Read the last played card on the game. // Read the last played card on the game.
get<Game.State> { body -> get<Game.State> { body ->
val state = body.game.id.buildStateFromEventStream(eventStream) val state = gameStateRepository.get(body.game.id)
call.respond(state) call.respond(state)
} }
} }

View File

@@ -2,7 +2,9 @@ package eventDemo.configuration
import eventDemo.app.command.GameCommandHandler import eventDemo.app.command.GameCommandHandler
import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.projection.GameStateRepository
import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.libs.event.EventBusInMemory import eventDemo.libs.event.EventBusInMemory
import eventDemo.libs.event.EventStreamInMemory import eventDemo.libs.event.EventStreamInMemory
@@ -26,10 +28,16 @@ val appKoinModule =
GameEventBus(EventBusInMemory()) GameEventBus(EventBusInMemory())
} }
single { single {
GameEventStream(get(), EventStreamInMemory()) GameEventStream(EventStreamInMemory())
} }
single { single {
GameCommandHandler(get()) GameStateRepository(get(), get())
}
single {
GameEventHandler(get(), get())
}
single {
GameCommandHandler(get(), get())
} }
singleOf(::GameEventPlayerNotificationListener) singleOf(::GameEventPlayerNotificationListener)

View File

@@ -5,6 +5,6 @@ import io.ktor.server.application.Application
import org.koin.ktor.ext.get import org.koin.ktor.ext.get
fun Application.configureGameListener() { fun Application.configureGameListener() {
GameEventReactionListener(get(), get()) GameEventReactionListener(get(), get(), get())
.init() .init()
} }

View File

@@ -0,0 +1,91 @@
package eventDemo.app.event.projection
import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameStartedEvent
import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.event.disableShuffleDeck
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import kotlin.test.assertIs
import kotlin.test.assertNotNull
class GameStateBuilderTest :
FunSpec({
test("apply") {
disableShuffleDeck()
val gameId = GameId()
val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein")
GameState(gameId)
.run {
val event = NewPlayerEvent(gameId, player1)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
state.isReady shouldBeEqual false
state.isStarted shouldBeEqual false
}
}.run {
val event = NewPlayerEvent(gameId, player2)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
state.players shouldBeEqual setOf(player1, player2)
}
}.run {
val event = PlayerReadyEvent(gameId, player1)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
state.readyPlayers shouldBeEqual setOf(player1)
}
}.run {
val event = PlayerReadyEvent(gameId, player2)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
state.readyPlayers shouldBeEqual setOf(player1, player2)
state.isReady shouldBeEqual true
state.isStarted shouldBeEqual false
}
}.run {
val event =
GameStartedEvent.new(
gameId,
setOf(player1, player2),
shuffleIsDisabled = true,
)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
state.isStarted shouldBeEqual true
assertIs<Card.NumericCard>(state.deck.stack.first()).let {
it.number shouldBeEqual 6
it.color shouldBeEqual Card.Color.Red
}
}
}.run {
val playedCard = playableCards(player1)[0]
val event = CardIsPlayedEvent(gameId, playedCard, player1)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
assertNotNull(state.lastCard).card shouldBeEqual playedCard
assertIs<Card.NumericCard>(playedCard).let {
it.number shouldBeEqual 0
it.color shouldBeEqual Card.Color.Red
}
}
}.run {
val playedCard = playableCards(player2)[0]
val event = CardIsPlayedEvent(gameId, playedCard, player2)
apply(event).also { state ->
state.gameId shouldBeEqual gameId
assertNotNull(state.lastCard).card shouldBeEqual playedCard
assertIs<Card.NumericCard>(playedCard).let {
it.number shouldBeEqual 7
it.color shouldBeEqual Card.Color.Red
}
}
}
}
})

View File

@@ -1,11 +1,15 @@
package eventDemo.app.query package eventDemo.app.query
import eventDemo.app.GameState
import eventDemo.app.entity.Card import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameStartedEvent
import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.projection.GameState
import eventDemo.app.event.projection.GameStateRepository
import eventDemo.configuration.configure import eventDemo.configuration.configure
import eventDemo.configuration.makeJwt import eventDemo.configuration.makeJwt
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
@@ -20,9 +24,13 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication import io.ktor.server.testing.testApplication
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.koin.ktor.ext.inject import org.koin.ktor.ext.inject
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
class GameStateRouteTest : class GameStateRouteTest :
FunSpec({ FunSpec({
@@ -51,30 +59,56 @@ class GameStateRouteTest :
test("/game/{id}/card/last") { test("/game/{id}/card/last") {
testApplication { testApplication {
val id = GameId() val gameId = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue) val player1 = Player(name = "Nikola")
val player = Player(name = "Nikola") val player2 = Player(name = "Einstein")
var lastPlayedCard: Card? = null
application { application {
stopKoin() stopKoin()
configure() configure()
val eventStream by inject<GameEventStream>() val eventHandler by inject<GameEventHandler>()
eventStream.publish( val stateRepo by inject<GameStateRepository>()
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player), runBlocking {
CardIsPlayedEvent(id, card, player), eventHandler.handle(
// Other game NewPlayerEvent(gameId, player1),
CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow), player), NewPlayerEvent(gameId, player2),
PlayerReadyEvent(gameId, player1),
PlayerReadyEvent(gameId, player2),
GameStartedEvent.new(
gameId,
setOf(player1, player2),
shuffleIsDisabled = true,
),
) )
delay(100)
lastPlayedCard = stateRepo.get(gameId).playableCards(player1).first()
assertNotNull(lastPlayedCard)
.let { assertIs<Card.NumericCard>(lastPlayedCard) }
.let {
it.number shouldBeEqual 0
it.color shouldBeEqual Card.Color.Red
}
delay(100)
eventHandler.handle(
CardIsPlayedEvent(
gameId,
assertNotNull(lastPlayedCard),
player1,
),
)
delay(100)
}
} }
httpClient() httpClient()
.get("/game/$id/card/last") { .get("/game/$gameId/card/last") {
withAuth(player) withAuth(player1)
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
}.apply { }.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
assertEquals(card, call.body<Card>()) assertEquals(assertNotNull(lastPlayedCard), call.body<Card>())
} }
} }
} }

View File

@@ -1,6 +1,5 @@
package eventDemo.app.query package eventDemo.app.query
import eventDemo.app.GameState
import eventDemo.app.command.GameCommandHandler import eventDemo.app.command.GameCommandHandler
import eventDemo.app.command.command.IWantToJoinTheGameCommand import eventDemo.app.command.command.IWantToJoinTheGameCommand
import eventDemo.app.command.command.IWantToPlayCardCommand import eventDemo.app.command.command.IWantToPlayCardCommand
@@ -8,8 +7,9 @@ import eventDemo.app.command.command.IamReadyToPlayCommand
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.event.disableShuffleDeck import eventDemo.app.event.event.disableShuffleDeck
import eventDemo.app.event.projection.GameState
import eventDemo.app.event.projection.buildStateFromEventStream
import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.app.eventListener.GameEventReactionListener import eventDemo.app.eventListener.GameEventReactionListener
import eventDemo.app.notification.PlayerAsJoinTheGameNotification import eventDemo.app.notification.PlayerAsJoinTheGameNotification
@@ -52,7 +52,7 @@ class GameStateTest :
val commandHandler by inject<GameCommandHandler>() val commandHandler by inject<GameCommandHandler>()
val playerNotificationListener by inject<GameEventPlayerNotificationListener>() val playerNotificationListener by inject<GameEventPlayerNotificationListener>()
val eventStream by inject<GameEventStream>() val eventStream by inject<GameEventStream>()
GameEventReactionListener(get(), get()).init() GameEventReactionListener(get(), get(), get()).init()
playerNotificationListener.startListening(channelOut1, player1) playerNotificationListener.startListening(channelOut1, player1)
playerNotificationListener.startListening(channelOut2, player2) playerNotificationListener.startListening(channelOut2, player2)