Create test for complete game

create notifications for reply the players
implement notifications on GameEventPlayerNotificationListener
add priority to the eventbus.subscribe()
improve JWT creation
update libs koin + ktor
remove output of GameCommandStream
improve logs
create a function disableShuffleDeck to disable the shuffle of the deck (for tests)
This commit is contained in:
2025-03-08 01:07:45 +01:00
parent 99f0760d3c
commit 51d857513c
55 changed files with 659 additions and 235 deletions

View File

@@ -54,11 +54,15 @@ dependencies {
implementation("io.ktor:ktor-server-netty-jvm") implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-data-conversion") implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation") implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:$logback_version") implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version") implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
testImplementation("io.kotest:kotest-extensions-koin:6.0.0.M2")
testImplementation("io.ktor:ktor-server-tests-jvm") testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
testImplementation("io.ktor:ktor-server-test-host-jvm:2.3.11") testImplementation("io.ktor:ktor-server-test-host-jvm:2.3.11")

View File

@@ -1,10 +1,8 @@
ktor_version=2.3.13 ktor_version=3.0.3
#ktor_version=3.0.3
kotlin_version=2.1.10 kotlin_version=2.1.10
kotlin_serialization_version=1.8.0 kotlin_serialization_version=1.8.0
logback_version=1.5.16 logback_version=1.5.16
koin_version=3.5.6 koin_version=4.0.2
# koin_version=4.0.2
kotlin_logging_version=5.1.0 kotlin_logging_version=5.1.0
#kotlin_logging_version=7.0.4 #kotlin_logging_version=7.0.4
kotest_version=5.9.1 kotest_version=5.9.1

View File

@@ -14,7 +14,7 @@ data class GameState(
val lastCard: LastCard? = null, val lastCard: LastCard? = null,
val lastColor: Card.Color? = null, val lastColor: Card.Color? = null,
val direction: Direction = Direction.CLOCKWISE, val direction: Direction = Direction.CLOCKWISE,
val readyPlayers: List<Player> = emptyList(), val readyPlayers: Set<Player> = emptySet(),
val deck: Deck = Deck(players), val deck: Deck = Deck(players),
val isStarted: Boolean = false, val isStarted: Boolean = false,
) { ) {
@@ -51,6 +51,8 @@ data class GameState(
} }
private val nextPlayerIndex: Int get() { private val nextPlayerIndex: Int get() {
if (players.size == 0) return 0
val y = val y =
if (direction == Direction.CLOCKWISE) { if (direction == Direction.CLOCKWISE) {
+1 +1
@@ -61,7 +63,13 @@ data class GameState(
return ((lastPlayerIndex ?: 0) + y) % players.size return ((lastPlayerIndex ?: 0) + y) % players.size
} }
val nextPlayer: Player = players.elementAt(nextPlayerIndex) val nextPlayer: Player? by lazy {
if (players.isEmpty()) {
null
} else {
players.elementAt(nextPlayerIndex)
}
}
val Player.currentIndex: Int get() = players.indexOf(this) val Player.currentIndex: Int get() = players.indexOf(this)
@@ -79,7 +87,8 @@ data class GameState(
fun playableCards(player: Player): List<Card> = fun playableCards(player: Player): List<Card> =
deck deck
.playersHands[player] .playersHands
.getHand(player)
?.filter { canBePlayThisCard(player, it) } ?.filter { canBePlayThisCard(player, it) }
?: emptyList() ?: emptyList()

View File

@@ -10,15 +10,13 @@ import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.libs.command.CommandBlock import eventDemo.app.notification.ErrorNotification
import eventDemo.shared.toFrame
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
/** /**
* Listen [GameCommand] on [GameCommandStream], check the validity and execute an action. * Listen [GameCommand] on [GameCommandStream], check the validity and execute an action.
@@ -28,26 +26,32 @@ import kotlinx.coroutines.launch
class GameCommandHandler( class GameCommandHandler(
private val eventStream: GameEventStream, private val eventStream: GameEventStream,
) { ) {
private val logger = KotlinLogging.logger { }
/** /**
* Init the handler * Init the handler
*/ */
@OptIn(DelicateCoroutinesApi::class) suspend fun handle(
fun handle(
player: Player, player: Player,
incoming: ReceiveChannel<Frame>, incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>, outgoing: SendChannel<Frame>,
): Job { ) {
val commandStream = GameCommandStream(incoming, outgoing) val commandStream = GameCommandStream(incoming)
val playerNotifier: (String) -> Unit = { outgoing.trySendBlocking(Frame.Text(it)) } val playerErrorNotifier: (String) -> Unit = {
return GlobalScope.launch { val notification = ErrorNotification(message = it)
init(player, commandStream, playerNotifier) logger.atInfo {
message = "Notification send ERROR: ${notification.message}"
payload = mapOf("notification" to notification)
} }
outgoing.trySendBlocking(notification.toFrame())
}
return init(player, commandStream, playerErrorNotifier)
} }
private suspend fun init( private suspend fun init(
player: Player, player: Player,
commandStream: GameCommandStream, commandStream: GameCommandStream,
playerNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
) { ) {
commandStream.process { command -> commandStream.process { command ->
if (command.payload.player.id != player.id) { if (command.payload.player.id != player.id) {
@@ -57,12 +61,12 @@ class GameCommandHandler(
val gameState = command.buildGameState() val gameState = command.buildGameState()
when (command) { when (command) {
is IWantToPlayCardCommand -> command.run(gameState, playerNotifier, eventStream) is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IamReadyToPlayCommand -> command.run(gameState, playerNotifier, eventStream) is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IWantToJoinTheGameCommand -> command.run(gameState, playerNotifier, eventStream) is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is ICantPlayCommand -> command.run(gameState, playerNotifier, eventStream) is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream)
}
} }
} as CommandBlock<GameCommand>
} }
private fun GameCommand.buildGameState(): GameState = payload.gameId.buildStateFromEventStream(eventStream) private fun GameCommand.buildGameState(): GameState = payload.gameId.buildStateFromEventStream(eventStream)

View File

@@ -8,15 +8,22 @@ import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal import io.ktor.server.auth.principal
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@DelicateCoroutinesApi
fun Route.gameSocket( fun Route.gameSocket(
playerNotificationListener: GameEventPlayerNotificationListener, playerNotificationListener: GameEventPlayerNotificationListener,
commandHandler: GameCommandHandler, commandHandler: GameCommandHandler,
) { ) {
authenticate { authenticate {
webSocket("/game") { webSocket("/game") {
commandHandler.handle(call.getPlayer(), incoming, outgoing) val currentPlayer = call.getPlayer()
playerNotificationListener.startListening(outgoing) GlobalScope.launch {
commandHandler.handle(currentPlayer, incoming, outgoing)
}
playerNotificationListener.startListening(outgoing, currentPlayer)
} }
} }
} }

View File

@@ -6,7 +6,6 @@ import eventDemo.libs.command.CommandStreamChannel
import eventDemo.libs.command.CommandStreamInMemory import eventDemo.libs.command.CommandStreamInMemory
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/** /**
@@ -19,10 +18,7 @@ class GameCommandStreamInMemory : CommandStreamInMemory<GameCommand>()
*/ */
class GameCommandStream( class GameCommandStream(
incoming: ReceiveChannel<Frame>, incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
) : CommandStream<GameCommand> by CommandStreamChannel( ) : CommandStream<GameCommand> by CommandStreamChannel(
incoming, incoming,
outgoing,
{ Json.encodeToString(GameCommand.serializer(), it) },
{ Json.decodeFromString(GameCommand.serializer(), it) }, { Json.decodeFromString(GameCommand.serializer(), it) },
) )

View File

@@ -6,14 +6,12 @@ import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.PlayerHavePassEvent import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
*/ */
@Serializable @Serializable
@SerialName("Pass")
data class ICantPlayCommand( data class ICantPlayCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
@@ -27,19 +25,22 @@ data class ICantPlayCommand(
fun run( fun run(
state: GameState, state: GameState,
playerNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream, eventStream: GameEventStream,
) { ) {
val playableCards = state.playableCards(payload.player) val playableCards = state.playableCards(payload.player)
if (playableCards.isEmpty()) { if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first()
eventStream.publish( eventStream.publish(
PlayerHavePassEvent( PlayerHavePassEvent(
payload.gameId, gameId = payload.gameId,
payload.player, player = payload.player,
takenCard = takenCard,
), ),
) )
} else { } else {
playerNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}") playerErrorNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}")
} }
} }
} }

View File

@@ -6,14 +6,13 @@ import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.NewPlayerEvent import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
*/ */
@Serializable @Serializable
@SerialName("JoinGame")
data class IWantToJoinTheGameCommand( data class IWantToJoinTheGameCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
@@ -27,9 +26,10 @@ data class IWantToJoinTheGameCommand(
fun run( fun run(
state: GameState, state: GameState,
playerNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream, eventStream: GameEventStream,
) { ) {
val logger = KotlinLogging.logger {}
if (!state.isStarted) { if (!state.isStarted) {
eventStream.publish( eventStream.publish(
NewPlayerEvent( NewPlayerEvent(
@@ -38,7 +38,11 @@ data class IWantToJoinTheGameCommand(
), ),
) )
} else { } else {
playerNotifier("The game is already started") logger.atWarn {
message = "The game is already started"
payload = mapOf("player" to this@IWantToJoinTheGameCommand.payload.player)
}
playerErrorNotifier("The game is already started")
} }
} }
} }

View File

@@ -7,14 +7,12 @@ import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
*/ */
@Serializable @Serializable
@SerialName("PlayCard")
data class IWantToPlayCardCommand( data class IWantToPlayCardCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
@@ -29,11 +27,11 @@ data class IWantToPlayCardCommand(
fun run( fun run(
state: GameState, state: GameState,
playerNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream, eventStream: GameEventStream,
) { ) {
if (!state.isReady) { if (!state.isStarted) {
playerNotifier("The game is Not started") playerErrorNotifier("The game is Not started")
return return
} }
@@ -46,7 +44,7 @@ data class IWantToPlayCardCommand(
), ),
) )
} else { } else {
playerNotifier("You cannot play this card") playerErrorNotifier("You cannot play this card")
} }
} }
} }

View File

@@ -6,14 +6,12 @@ import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.PlayerReadyEvent import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to set as ready to play * A command to set as ready to play
*/ */
@Serializable @Serializable
@SerialName("Ready")
data class IamReadyToPlayCommand( data class IamReadyToPlayCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
@@ -27,13 +25,16 @@ data class IamReadyToPlayCommand(
fun run( fun run(
state: GameState, state: GameState,
playerNotifier: (String) -> Unit, playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream, eventStream: GameEventStream,
) { ) {
val playerExist: Boolean = state.players.contains(payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player) val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player)
if (playerIsAlreadyReady) { if (!playerExist) {
playerNotifier("You are already ready") playerErrorNotifier("You are not in the game")
} else if (playerIsAlreadyReady) {
playerErrorNotifier("You are already ready")
} else { } else {
eventStream.publish( eventStream.publish(
PlayerReadyEvent( PlayerReadyEvent(

View File

@@ -11,6 +11,8 @@ data class Deck(
constructor(players: Set<Player>) : constructor(players: Set<Player>) :
this(playersHands = PlayersHands(players)) this(playersHands = PlayersHands(players))
fun shuffle(): Deck = copy(stack = stack.shuffle())
fun placeFirstCardOnDiscard(): Deck { fun placeFirstCardOnDiscard(): Deck {
val takenCard = stack.first() val takenCard = stack.first()
return copy( return copy(
@@ -21,16 +23,9 @@ data class Deck(
fun takeOneCardFromStackTo(player: Player): Deck = fun takeOneCardFromStackTo(player: Player): Deck =
takeOne().let { (deck, newPlayerCard) -> takeOne().let { (deck, newPlayerCard) ->
val newHands = deck.copy(
deck.playersHands playersHands = deck.playersHands.addCard(player, newPlayerCard),
.mapValues { (p, cards) -> )
if (p == player) {
cards + newPlayerCard
} else {
cards
}
}.toPlayersHands()
deck.copy(playersHands = newHands)
} }
fun putOneCardFromHand( fun putOneCardFromHand(
@@ -40,7 +35,7 @@ data class Deck(
run { run {
// Validate parameters // Validate parameters
val playerHand = val playerHand =
playersHands[player] playersHands.getHand(player)
?: error("No player on this game") ?: error("No player on this game")
if (playerHand.none { it == card }) { if (playerHand.none { it == card }) {
error("No card exist on the player hand") error("No card exist on the player hand")
@@ -70,8 +65,7 @@ data class Deck(
(1..2).map { Card.PassCard(color) } (1..2).map { Card.PassCard(color) }
}.let { }.let {
it + (1..4).map { Card.Plus4Card() } it + (1..4).map { Card.Plus4Card() }
}.shuffled() }.toStack()
.toStack()
.let { Deck(it) } .let { Deck(it) }
} }
} }
@@ -100,6 +94,8 @@ value class Stack(
operator fun plus(card: Card): Stack = cards.plus(card).toStack() operator fun plus(card: Card): Stack = cards.plus(card).toStack()
operator fun minus(card: Card): Stack = cards.minus(card).toStack() operator fun minus(card: Card): Stack = cards.minus(card).toStack()
fun shuffle(): Stack = shuffled().toStack()
} }
fun List<Card>.toStack(): Stack = Stack(this.toSet()) fun List<Card>.toStack(): Stack = Stack(this.toSet())

View File

@@ -12,4 +12,6 @@ import java.util.UUID
@Serializable(with = GameIdSerializer::class) @Serializable(with = GameIdSerializer::class)
value class GameId( value class GameId(
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId ) : AggregateId {
override fun toString(): String = id.toString()
}

View File

@@ -18,6 +18,7 @@ data class Player(
PlayerId(UUID.fromString(id)), PlayerId(UUID.fromString(id)),
) )
@Serializable
@JvmInline @JvmInline
value class PlayerId( value class PlayerId(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)

View File

@@ -5,28 +5,38 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
@JvmInline @JvmInline
value class PlayersHands( value class PlayersHands(
private val map: Map<Player, List<Card>> = emptyMap(), private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
) : Map<Player, List<Card>> by map { ) : Map<Player.PlayerId, List<Card>> by map {
constructor(players: Set<Player>) : this(players.associateWith { emptyList<Card>() }.toPlayersHands()) constructor(players: Set<Player>) :
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
fun getHand(player: Player): List<Card>? = this[player.id]
fun removeCard( fun removeCard(
player: Player, player: Player,
card: Card, card: Card,
): PlayersHands = ): PlayersHands =
mapValues { (p, cards) -> mapValues { (playerId, cards) ->
if (p == player) { if (playerId == player.id) {
if (!cards.contains(card)) error("The hand no contain the card")
cards - card cards - card
} else { } else {
cards cards
} }
}.toPlayersHands() }.toPlayersHands()
fun addCard(
player: Player,
newCard: Card,
): PlayersHands = addCards(player, listOf(newCard))
fun addCards( fun addCards(
player: Player, player: Player,
newCards: List<Card>, newCards: List<Card>,
): PlayersHands = ): PlayersHands =
mapValues { (p, cards) -> mapValues { (p, cards) ->
if (p == player) { if (p == player.id) {
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
cards + newCards cards + newCards
} else { } else {
cards cards
@@ -34,4 +44,4 @@ value class PlayersHands(
}.toPlayersHands() }.toPlayersHands()
} }
fun Map<Player, List<Card>>.toPlayersHands(): PlayersHands = PlayersHands(this) fun Map<Player.PlayerId, List<Card>>.toPlayersHands(): PlayersHands = PlayersHands(this)

View File

@@ -10,13 +10,17 @@ 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.libs.event.EventStream
fun GameId.buildStateFromEventStream(eventStream: EventStream<GameEvent, GameId>): GameState = fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState =
buildStateFromEvents( buildStateFromEvents(
eventStream.readAll(this), eventStream.readAll(this),
) )
fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState =
gameId.buildStateFromEvents(
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: GameState, event: GameEvent ->
when (event) { when (event) {
@@ -37,12 +41,13 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
lastPlayer = event.player, lastPlayer = event.player,
direction = direction, direction = direction,
lastColor = color, lastColor = color,
lastCard = GameState.LastCard(event.card, event.player),
deck = state.deck.putOneCardFromHand(event.player, event.card), deck = state.deck.putOneCardFromHand(event.player, event.card),
) )
} }
is NewPlayerEvent -> { is NewPlayerEvent -> {
if (state.isReady) error("The game is already started") if (state.isStarted) error("The game is already started")
state.copy( state.copy(
players = state.players + event.player, players = state.players + event.player,
@@ -56,6 +61,7 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
} }
is PlayerHavePassEvent -> { is PlayerHavePassEvent -> {
if (event.takenCard != state.deck.stack.first()) error("taken card is not ot top of the stack")
state.copy( state.copy(
lastPlayer = event.player, lastPlayer = event.player,
deck = state.deck.takeOneCardFromStackTo(event.player), deck = state.deck.takeOneCardFromStackTo(event.player),
@@ -70,7 +76,7 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
is GameStartedEvent -> { is GameStartedEvent -> {
state.copy( state.copy(
lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color, lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.lastColor,
lastCard = GameState.LastCard(event.deck.discard.first(), event.firstPlayer), lastCard = GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
lastPlayer = event.firstPlayer, lastPlayer = event.firstPlayer,
deck = event.deck, deck = event.deck,

View File

@@ -8,7 +8,7 @@ import eventDemo.app.entity.Player
* An [GameEvent] to represent a played card. * An [GameEvent] to represent a played card.
*/ */
data class CardIsPlayedEvent( data class CardIsPlayedEvent(
override val id: GameId, override val gameId: GameId,
val card: Card, val card: Card,
val player: Player, val player: Player,
) : GameEvent ) : GameEvent

View File

@@ -9,5 +9,5 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
sealed interface GameEvent : Event<GameId> { sealed interface GameEvent : Event<GameId> {
override val id: GameId override val gameId: GameId
} }

View File

@@ -9,7 +9,7 @@ import eventDemo.app.entity.initHands
* This [GameEvent] is sent when all players are ready. * This [GameEvent] is sent when all players are ready.
*/ */
data class GameStartedEvent( data class GameStartedEvent(
override val id: GameId, override val gameId: GameId,
val firstPlayer: Player, val firstPlayer: Player,
val deck: Deck, val deck: Deck,
) : GameEvent { ) : GameEvent {
@@ -19,9 +19,20 @@ data class GameStartedEvent(
players: Set<Player>, players: Set<Player>,
): GameStartedEvent = ): GameStartedEvent =
GameStartedEvent( GameStartedEvent(
id = id, gameId = id,
firstPlayer = players.random(), firstPlayer = if (isDisabled) players.first() else players.random(),
deck = Deck.newWithoutPlayers().initHands(players).placeFirstCardOnDiscard(), deck =
Deck
.newWithoutPlayers()
.let { if (isDisabled) it else it.shuffle() }
.initHands(players)
.placeFirstCardOnDiscard(),
) )
} }
} }
private var isDisabled = false
internal fun disableShuffleDeck() {
isDisabled = true
}

View File

@@ -7,6 +7,6 @@ import eventDemo.app.entity.Player
* An [GameEvent] to represent a new player joining the game. * An [GameEvent] to represent a new player joining the game.
*/ */
data class NewPlayerEvent( data class NewPlayerEvent(
override val id: GameId, override val gameId: GameId,
val player: Player, val player: Player,
) : GameEvent ) : GameEvent

View File

@@ -8,7 +8,7 @@ import eventDemo.app.entity.Player
* This [GameEvent] is sent when a player chose a color. * This [GameEvent] is sent when a player chose a color.
*/ */
data class PlayerChoseColorEvent( data class PlayerChoseColorEvent(
override val id: GameId, override val gameId: GameId,
val player: Player, val player: Player,
val color: Card.Color, val color: Card.Color,
) : GameEvent ) : GameEvent

View File

@@ -1,5 +1,6 @@
package eventDemo.app.event.event package eventDemo.app.event.event
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
@@ -7,6 +8,7 @@ import eventDemo.app.entity.Player
* This [GameEvent] is sent when a player can play. * This [GameEvent] is sent when a player can play.
*/ */
data class PlayerHavePassEvent( data class PlayerHavePassEvent(
override val id: GameId, override val gameId: GameId,
val player: Player, val player: Player,
val takenCard: Card,
) : GameEvent ) : GameEvent

View File

@@ -7,6 +7,6 @@ import eventDemo.app.entity.Player
* This [GameEvent] is sent when a player is ready. * This [GameEvent] is sent when a player is ready.
*/ */
data class PlayerReadyEvent( data class PlayerReadyEvent(
override val id: GameId, override val gameId: GameId,
val player: Player, val player: Player,
) : GameEvent ) : GameEvent

View File

@@ -1,18 +1,126 @@
package eventDemo.app.eventListener package eventDemo.app.eventListener
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventBus 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.GameEvent
import eventDemo.app.event.event.GameStartedEvent
import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerChoseColorEvent
import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.notification.PlayerAsJoinTheGameNotification
import eventDemo.app.notification.PlayerAsPlayACardNotification
import eventDemo.app.notification.PlayerHavePassNotification
import eventDemo.app.notification.PlayerWasChoseTheCardColorNotification
import eventDemo.app.notification.PlayerWasReadyNotification
import eventDemo.app.notification.TheGameWasStartedNotification
import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.app.notification.YourNewCardNotification
import eventDemo.shared.toFrame import eventDemo.shared.toFrame
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
class GameEventPlayerNotificationListener( class GameEventPlayerNotificationListener(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val eventStream: GameEventStream,
) {
private val logger = KotlinLogging.logger {}
fun startListening(
outgoing: SendChannel<Frame>,
currentPlayer: Player,
) { ) {
fun startListening(outgoing: SendChannel<Frame>) {
eventBus.subscribe { event: GameEvent -> eventBus.subscribe { event: GameEvent ->
outgoing.trySendBlocking(event.toFrame()) val currentState = event.buildStateFromEventStreamTo(eventStream)
val notification =
when (event) {
is NewPlayerEvent -> {
if (currentPlayer != event.player) {
PlayerAsJoinTheGameNotification(
player = event.player,
)
} else {
WelcomeToTheGameNotification(
players = currentState.players,
)
}
}
is CardIsPlayedEvent -> {
if (currentPlayer != event.player) {
PlayerAsPlayACardNotification(
player = event.player,
card = event.card,
)
} else {
null
}
}
is GameStartedEvent -> {
TheGameWasStartedNotification(
hand =
event.deck.playersHands.getHand(currentPlayer)
?: error("You are not in the game"),
)
}
is PlayerChoseColorEvent -> {
if (currentPlayer != event.player) {
PlayerWasChoseTheCardColorNotification(
player = event.player,
color = event.color,
)
} else {
null
}
}
is PlayerHavePassEvent -> {
if (currentPlayer == event.player) {
YourNewCardNotification(
card = event.takenCard,
)
} else {
PlayerHavePassNotification(
player = event.player,
)
}
}
is PlayerReadyEvent -> {
if (currentPlayer != event.player) {
PlayerWasReadyNotification(
player = event.player,
)
} else {
null
}
}
}
if (notification == null) {
logger.atInfo {
message = "Notification Ignore: $event"
payload = mapOf("event" to event)
}
} else if (currentState.players.contains(currentPlayer)) {
// Only notify players who have already joined the game.
outgoing.trySendBlocking(notification.toFrame())
logger.atInfo {
message = "Notification SEND: $notification"
payload = mapOf("notification" to notification, "event" to event)
}
} else {
logger.atInfo {
message = "Notification SKIP: $notification"
payload = mapOf("notification" to notification, "event" to event)
}
}
} }
} }
} }

View File

@@ -2,25 +2,41 @@ package eventDemo.app.eventListener
import eventDemo.app.event.GameEventBus import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream import eventDemo.app.event.buildStateFromEventStreamTo
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent import eventDemo.app.event.event.GameStartedEvent
import io.github.oshai.kotlinlogging.KotlinLogging
class GameEventReactionListener( class GameEventReactionListener(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val eventStream: GameEventStream, private val eventStream: GameEventStream,
private val priority: Int = DEFAULT_PRIORITY,
) { ) {
companion object Config {
const val DEFAULT_PRIORITY = -1000
}
private val logger = KotlinLogging.logger { }
fun init() { fun init() {
eventBus.subscribe { event: GameEvent -> eventBus.subscribe(priority) { event: GameEvent ->
val state = event.id.buildStateFromEventStream(eventStream) val state = event.buildStateFromEventStreamTo(eventStream)
if (state.isReady) { if (state.isReady && !state.isStarted) {
eventStream.publish( val reactionEvent =
GameStartedEvent.new( GameStartedEvent.new(
state.gameId, state.gameId,
state.players, state.players,
), )
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,12 @@
package eventDemo.app.notification
import eventDemo.shared.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class ErrorNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val message: String,
) : Notification

View File

@@ -0,0 +1,11 @@
package eventDemo.app.notification
import eventDemo.shared.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
sealed interface Notification {
@Serializable(with = UUIDSerializer::class)
val id: UUID
}

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 PlayerAsJoinTheGameNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val player: Player,
) : Notification

View File

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

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 PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val player: Player,
) : Notification

View File

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

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 PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val player: Player,
) : Notification

View File

@@ -0,0 +1,13 @@
package eventDemo.app.notification
import eventDemo.app.entity.Card
import eventDemo.shared.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class TheGameWasStartedNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val hand: List<Card>,
) : Notification

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 WelcomeToTheGameNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val players: Set<Player>,
) : Notification

View File

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

View File

@@ -1,6 +1,5 @@
package eventDemo.configuration package eventDemo.configuration
import eventDemo.app.eventListener.GameEventReactionListener
import io.ktor.server.application.Application import io.ktor.server.application.Application
import org.koin.ktor.ext.get import org.koin.ktor.ext.get
@@ -17,6 +16,5 @@ fun Application.configure() {
configureHttpRouting() configureHttpRouting()
declareHttpGameRoute() declareHttpGameRoute()
GameEventReactionListener(get(), get()) configureGameListener()
.init()
} }

View File

@@ -2,6 +2,7 @@ package eventDemo.configuration
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import eventDemo.app.entity.Player
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.call import io.ktor.server.application.call
@@ -11,13 +12,15 @@ import io.ktor.server.auth.jwt.jwt
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
import java.util.Date import java.util.Date
fun Application.configureSecurity() {
// TODO: read the jwt property from the config file // TODO: read the jwt property from the config file
val jwtRealm = "Play card game" private val jwtRealm = "Play card game"
val jwtIssuer = "PlayCardGame" private val jwtIssuer = "PlayCardGame"
val jwtSecret = "secret" private val jwtSecret = "secret"
fun Application.configureSecurity() {
authentication { authentication {
jwt { jwt {
realm = jwtRealm realm = jwtRealm
@@ -42,17 +45,19 @@ fun Application.configureSecurity() {
routing { routing {
post("login/{username}") { post("login/{username}") {
val username = call.parameters["username"] val username = call.parameters["username"]!!
val player = Player(name = username)
val token = call.respond(hashMapOf("token" to player.makeJwt()))
}
}
}
fun Player.makeJwt(): String =
JWT JWT
.create() .create()
.withIssuer(jwtIssuer) .withIssuer(jwtIssuer)
.withClaim("username", username) .withClaim("username", name)
.withPayload(Json.encodeToString(this))
.withExpiresAt(Date(System.currentTimeMillis() + 60000)) .withExpiresAt(Date(System.currentTimeMillis() + 60000))
.sign(Algorithm.HMAC256(jwtSecret)) .sign(Algorithm.HMAC256(jwtSecret))
call.respond(hashMapOf("token" to token))
}
}
}

View File

@@ -0,0 +1,10 @@
package eventDemo.configuration
import eventDemo.app.eventListener.GameEventReactionListener
import io.ktor.server.application.Application
import org.koin.ktor.ext.get
fun Application.configureGameListener() {
GameEventReactionListener(get(), get())
.init()
}

View File

@@ -1,5 +1,7 @@
package eventDemo.configuration package eventDemo.configuration
import eventDemo.app.entity.GameId
import eventDemo.shared.GameIdSerializer
import eventDemo.shared.UUIDSerializer import eventDemo.shared.UUIDSerializer
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application import io.ktor.server.application.Application
@@ -16,6 +18,7 @@ fun Application.configureSerialization() {
serializersModule = serializersModule =
SerializersModule { SerializersModule {
contextual(UUID::class) { UUIDSerializer } contextual(UUID::class) { UUIDSerializer }
contextual(GameId::class) { GameIdSerializer }
} }
}, },
) )

View File

@@ -5,7 +5,9 @@ import eventDemo.app.command.gameSocket
import eventDemo.app.eventListener.GameEventPlayerNotificationListener import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.coroutines.DelicateCoroutinesApi
@OptIn(DelicateCoroutinesApi::class)
fun Application.declareWebSocketsGameRoute( fun Application.declareWebSocketsGameRoute(
playerNotificationListener: GameEventPlayerNotificationListener, playerNotificationListener: GameEventPlayerNotificationListener,
commandHandler: GameCommandHandler, commandHandler: GameCommandHandler,

View File

@@ -3,7 +3,6 @@ package eventDemo.libs.command
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.reflect.KClass
/** /**
* Represent a Command stream. * Represent a Command stream.
@@ -11,24 +10,6 @@ import kotlin.reflect.KClass
* The stream contains a list of all actions yet to be executed. * The stream contains a list of all actions yet to be executed.
*/ */
interface CommandStream<C : Command> { interface CommandStream<C : Command> {
/**
* Send a new [Command] to the queue.
*/
fun send(
type: KClass<C>,
command: C,
)
/**
* Send multiple [Command] to the queue.
*/
fun send(
type: KClass<C>,
vararg commands: C,
) {
commands.forEach { send(type, it) }
}
/** /**
* A class to implement success/failed action. * A class to implement success/failed action.
*/ */
@@ -50,5 +31,3 @@ interface CommandStream<C : Command> {
} }
} }
} }
suspend inline fun <reified C : Command> CommandStream<C>.send(vararg command: C) = send(C::class, *command)

View File

@@ -4,45 +4,16 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.onSuccess
import kotlinx.coroutines.channels.trySendBlocking
import kotlin.reflect.KClass
/** /**
* Manage [Command]'s with kotlin Channel * Manage [Command]'s with kotlin Channel
*/ */
class CommandStreamChannel<C : Command>( class CommandStreamChannel<C : Command>(
private val incoming: ReceiveChannel<Frame>, private val incoming: ReceiveChannel<Frame>,
private val outgoing: SendChannel<Frame>,
private val serializer: (C) -> String,
private val deserializer: (String) -> C, private val deserializer: (String) -> C,
) : CommandStream<C> { ) : CommandStream<C> {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
/**
* Send a new [Command] to the queue.
*/
override fun send(
type: KClass<C>,
command: C,
) {
outgoing
.trySendBlocking(Frame.Text(serializer(command)))
.onSuccess {
logger.atInfo {
message = "Command published: $command"
payload = mapOf("command" to command)
}
}.onFailure {
logger.atError {
message = "Command FAILED: $command"
payload = mapOf("command" to command)
}
}
}
override suspend fun process(action: CommandBlock<C>) { override suspend fun process(action: CommandBlock<C>) {
// incoming.consumeEach { commandAsFrame -> // incoming.consumeEach { commandAsFrame ->
// if (commandAsFrame is Frame.Text) { // if (commandAsFrame is Frame.Text) {
@@ -90,17 +61,15 @@ class CommandStreamChannel<C : Command>(
private suspend fun markAsSuccess(command: C) { private suspend fun markAsSuccess(command: C) {
logger.atInfo { logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack" message = "Compute command SUCCESS: $command"
payload = mapOf("command" to command) payload = mapOf("command" to command)
} }
// outgoing.trySendBlocking(Frame.Text("Command executed successfully"))
} }
private suspend fun markAsFailed(command: C) { private suspend fun markAsFailed(command: C) {
logger.atWarn { logger.atWarn {
message = "Compute command FAILED" message = "Compute command FAILED: $command"
payload = mapOf("command" to command) payload = mapOf("command" to command)
} }
// outgoing.trySendBlocking(Frame.Text("Command execution failed"))
} }
} }

View File

@@ -3,8 +3,6 @@ package eventDemo.libs.command
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.channels.trySendBlocking
import kotlin.reflect.KClass
typealias CommandBlock<C> = suspend CommandStream.ComputeStatus.(C) -> Unit typealias CommandBlock<C> = suspend CommandStream.ComputeStatus.(C) -> Unit
@@ -20,20 +18,6 @@ abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
logger.atWarn { "${it::class.simpleName} command not send" } logger.atWarn { "${it::class.simpleName} command not send" }
}) })
/**
* Send a new [Command] to the queue.
*/
override fun send(
type: KClass<C>,
command: C,
) {
logger.atInfo {
message = "Command published: $command"
payload = mapOf("command" to command)
}
queue.trySendBlocking(command)
}
override suspend fun process(action: CommandBlock<C>) { override suspend fun process(action: CommandBlock<C>) {
queue.consumeEach { command -> queue.consumeEach { command ->
compute(command, action) compute(command, action)
@@ -71,14 +55,14 @@ abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
private fun <C : Command> markAsSuccess(command: C) { private fun <C : Command> markAsSuccess(command: C) {
logger.atInfo { logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack : $command" message = "Compute command SUCCESS : $command"
payload = mapOf("command" to command) payload = mapOf("command" to command)
} }
} }
private fun <C : Command> markAsFailed(command: C) { private fun <C : Command> markAsFailed(command: C) {
logger.atWarn { logger.atWarn {
message = "Compute command FAILED and it put it ot the top of the stack : $command" message = "Compute command FAILED : $command"
payload = mapOf("command" to command) payload = mapOf("command" to command)
} }
} }

View File

@@ -15,5 +15,5 @@ interface AggregateId {
* @see EventStream * @see EventStream
*/ */
interface Event<ID : AggregateId> { interface Event<ID : AggregateId> {
val id: ID val gameId: ID
} }

View File

@@ -3,5 +3,11 @@ package eventDemo.libs.event
interface EventBus<E : Event<ID>, ID : AggregateId> { interface EventBus<E : Event<ID>, ID : AggregateId> {
fun publish(event: E) fun publish(event: E)
fun subscribe(block: (E) -> Unit) /**
* @param priority The higher the priority, the more it will be called first
*/
fun subscribe(
priority: Int = 0,
block: (E) -> Unit,
)
} }

View File

@@ -1,15 +1,20 @@
package eventDemo.libs.event package eventDemo.libs.event
class EventBusInMemory<E : Event<ID>, ID : AggregateId> : EventBus<E, ID> { class EventBusInMemory<E : Event<ID>, ID : AggregateId> : EventBus<E, ID> {
private val subscribers: MutableList<(E) -> Unit> = mutableListOf() private val subscribers: MutableList<Pair<Int, (E) -> Unit>> = mutableListOf()
override fun publish(event: E) { override fun publish(event: E) {
subscribers.forEach { subscribers
it(event) .sortedByDescending { (priority, block) -> priority }
.forEach { (_, block) ->
block(event)
} }
} }
override fun subscribe(block: (E) -> Unit) { override fun subscribe(
subscribers.add(block) priority: Int,
block: (E) -> Unit,
) {
subscribers.add(priority to block)
} }
} }

View File

@@ -32,7 +32,7 @@ class EventStreamInMemory<E : Event<ID>, ID : AggregateId> : EventStream<E, ID>
): R? = ): R? =
events events
.filterIsInstance(eventType.java) .filterIsInstance(eventType.java)
.lastOrNull { it.id == aggregateId } .lastOrNull { it.gameId == aggregateId }
override fun readAll(aggregateId: ID): List<E> = events override fun readAll(aggregateId: ID): List<E> = events
} }

View File

@@ -2,6 +2,7 @@ package eventDemo.shared
import eventDemo.app.command.command.GameCommand import eventDemo.app.command.command.GameCommand
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.app.notification.Notification
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -13,3 +14,11 @@ fun GameEvent.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameEvent.s
fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText()) fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText())
fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this)) fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this))
fun Frame.toNotification(): Notification =
Json.decodeFromString(
Notification.serializer(),
(this as Frame.Text).readText(),
)
fun Notification.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(Notification.serializer(), this))

View File

@@ -34,7 +34,7 @@ object PlayerIdSerializer : KSerializer<PlayerId> {
encoder.encodeString(value.id.toString()) encoder.encodeString(value.id.toString())
} }
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PlayerId", PrimitiveKind.STRING)
} }
object GameIdSerializer : KSerializer<GameId> { object GameIdSerializer : KSerializer<GameId> {

View File

@@ -1,8 +1,14 @@
package eventDemo package eventDemo
import eventDemo.app.command.command.GameCommand
import eventDemo.app.entity.Card import eventDemo.app.entity.Card
import eventDemo.app.entity.Deck import eventDemo.app.entity.Deck
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.SendChannel
import kotlinx.serialization.json.Json
fun Deck.allCardCount(): Int = stack.size + discard.size + playersHands.values.flatten().size fun Deck.allCardCount(): Int = stack.size + discard.size + playersHands.values.flatten().size
fun Deck.allCards(): Set<Card> = stack + discard + playersHands.values.flatten() fun Deck.allCards(): Set<Card> = stack + discard + playersHands.values.flatten()
suspend fun SendChannel<Frame>.send(command: GameCommand) = send(Frame.Text(Json.encodeToString(command)))

View File

@@ -55,9 +55,9 @@ class DeckTest :
modifiedDeck.discard.size shouldBeExactly 0 modifiedDeck.discard.size shouldBeExactly 0
modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1 modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1
modifiedDeck.playersHands.size shouldBeExactly playerNumbers modifiedDeck.playersHands.size shouldBeExactly playerNumbers
assertNotNull(modifiedDeck.playersHands[firstPlayer]).size shouldBeExactly 7 + 1 assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 7 + 1
modifiedDeck.playersHands modifiedDeck.playersHands
.filterKeys { it != firstPlayer } .filterKeys { it != firstPlayer.id }
.forEach { (_, cards) -> cards.size shouldBeExactly 7 } .forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
} }
@@ -70,16 +70,16 @@ class DeckTest :
val firstPlayer = players.first() val firstPlayer = players.first()
// When // When
val card = deck.playersHands[firstPlayer]!!.first() val card = deck.playersHands.getHand(firstPlayer)!!.first()
val modifiedDeck = deck.putOneCardFromHand(firstPlayer, card) val modifiedDeck = deck.putOneCardFromHand(firstPlayer, card)
// Then // Then
modifiedDeck.discard.size shouldBeExactly 1 modifiedDeck.discard.size shouldBeExactly 1
modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7)
modifiedDeck.playersHands.size shouldBeExactly playerNumbers modifiedDeck.playersHands.size shouldBeExactly playerNumbers
assertNotNull(modifiedDeck.playersHands[firstPlayer]).size shouldBeExactly 6 assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 6
modifiedDeck.playersHands modifiedDeck.playersHands
.filterKeys { it != firstPlayer } .filterKeys { it != firstPlayer.id }
.forEach { (_, cards) -> cards.size shouldBeExactly 7 } .forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
} }

View File

@@ -17,8 +17,8 @@ class PlayerHandKtTest :
// When // When
val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card)) val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card))
assertNotNull(newHands[firstPlayer]).size shouldBeExactly 1 assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1
assertNotNull(newHands[players.last()]).size shouldBeExactly 0 assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0
} }
test("removeCard") { test("removeCard") {
@@ -35,7 +35,7 @@ class PlayerHandKtTest :
// When // When
val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1) val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1)
assertNotNull(newHands[firstPlayer]).size shouldBeExactly 1 assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1
assertNotNull(newHands[players.last()]).size shouldBeExactly 0 assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0
} }
}) })

View File

@@ -1,49 +1,50 @@
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.GameEventStream
import eventDemo.app.event.event.CardIsPlayedEvent import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.configuration.configure import eventDemo.configuration.configure
import eventDemo.configuration.makeJwt
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.accept import io.ktor.client.request.accept
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.header
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType.Application.Json import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.testing.testApplication import io.ktor.server.testing.testApplication
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.koin.java.KoinJavaComponent.getKoin
import org.koin.ktor.ext.inject import org.koin.ktor.ext.inject
import kotlin.test.assertEquals import kotlin.test.assertEquals
class CardTest : class GameStateRouteTest :
FunSpec({ FunSpec({
test("/game/{id}/card") { test("/game/{id}/state on empty game") {
testApplication { testApplication {
val id = GameId()
val player1 = Player(name = "Nikola")
application { application {
stopKoin() stopKoin()
configure() configure()
} }
val id = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue)
val player = Player(name = "Nikola")
httpClient() httpClient()
.post("/game/$id/card") { .get("/game/$id/state") {
contentType(Json) withAuth(player1)
accept(Json) accept(ContentType.Application.Json)
setBody(card)
}.apply { }.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val state = call.body<GameState>()
val eventStream = getKoin().get<GameEventStream>() assertEquals(id, state.gameId)
assertEquals(CardIsPlayedEvent(id, card, player), eventStream.readLast(id)) state.players shouldHaveSize 0
state.isStarted shouldBeEqual false
} }
} }
} }
@@ -52,12 +53,13 @@ class CardTest :
testApplication { testApplication {
val id = GameId() val id = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue) val card: Card = Card.NumericCard(1, Card.Color.Blue)
val player = Player(name = "Nikola")
application { application {
stopKoin() stopKoin()
configure() configure()
val eventStream by inject<GameEventStream>() val eventStream by inject<GameEventStream>()
val player = Player(name = "Nikola")
eventStream.publish( eventStream.publish(
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player), CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player),
CardIsPlayedEvent(id, card, player), CardIsPlayedEvent(id, card, player),
@@ -66,10 +68,18 @@ class CardTest :
) )
} }
httpClient().get("/game/$id/card/last").apply { httpClient()
.get("/game/$id/card/last") {
withAuth(player)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
assertEquals(card, call.body<Card>()) assertEquals(card, call.body<Card>())
} }
} }
} }
}) })
private fun HttpRequestBuilder.withAuth(player: Player) {
header("Authorization", "Bearer ${player.makeJwt()}")
}

View File

@@ -0,0 +1,133 @@
package eventDemo.app.query
import eventDemo.app.GameState
import eventDemo.app.command.GameCommandHandler
import eventDemo.app.command.command.IWantToJoinTheGameCommand
import eventDemo.app.command.command.IWantToPlayCardCommand
import eventDemo.app.command.command.IamReadyToPlayCommand
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.app.event.event.disableShuffleDeck
import eventDemo.app.eventListener.GameEventPlayerNotificationListener
import eventDemo.app.eventListener.GameEventReactionListener
import eventDemo.app.notification.PlayerAsJoinTheGameNotification
import eventDemo.app.notification.PlayerAsPlayACardNotification
import eventDemo.app.notification.PlayerWasReadyNotification
import eventDemo.app.notification.TheGameWasStartedNotification
import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.configuration.appKoinModule
import eventDemo.send
import eventDemo.shared.toNotification
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual
import io.ktor.websocket.Frame
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.dsl.koinApplication
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@DelicateCoroutinesApi
class GameStateTest :
FunSpec({
test("Simulation of a game") {
disableShuffleDeck()
val id = GameId()
val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein")
val channelIn1 = Channel<Frame>(Channel.BUFFERED)
val channelIn2 = Channel<Frame>(Channel.BUFFERED)
val channelOut1 = Channel<Frame>(Channel.BUFFERED)
val channelOut2 = Channel<Frame>(Channel.BUFFERED)
koinApplication { modules(appKoinModule) }.koin.apply {
val commandHandler by inject<GameCommandHandler>()
val playerNotificationListener by inject<GameEventPlayerNotificationListener>()
val eventStream by inject<GameEventStream>()
GameEventReactionListener(get(), get()).init()
playerNotificationListener.startListening(channelOut1, player1)
playerNotificationListener.startListening(channelOut2, player2)
GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player1, channelIn1, channelOut1)
}
GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player2, channelIn2, channelOut2)
}
launch(Dispatchers.IO) {
channelIn1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1)))
}
launch(Dispatchers.IO) {
delay(200)
channelIn2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)))
}
channelOut1.receive().toNotification().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1)
}
channelOut2.receive().toNotification().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2)
}
channelOut1.receive().toNotification().let {
assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2
}
launch(Dispatchers.IO) {
channelIn1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)))
}
launch(Dispatchers.IO) {
channelIn2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)))
}
channelOut1.receive().toNotification().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2
}
channelOut2.receive().toNotification().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1
}
val player1Hand =
channelOut1.receive().toNotification().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
}
val player2Hand =
channelOut2.receive().toNotification().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
}
launch {
channelIn1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())))
}
channelOut2.receive().toNotification().let {
assertIs<PlayerAsPlayACardNotification>(it).player shouldBeEqual player1
assertIs<PlayerAsPlayACardNotification>(it).card shouldBeEqual player1Hand.first()
}
launch {
channelIn2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())))
}
channelOut1.receive().toNotification().let {
assertIs<PlayerAsPlayACardNotification>(it).player shouldBeEqual player2
assertIs<PlayerAsPlayACardNotification>(it).card shouldBeEqual player2Hand.first()
}
val state = id.buildStateFromEventStream(eventStream)
state.gameId shouldBeEqual id
assertTrue(state.isStarted)
state.players shouldBeEqual setOf(player1, player2)
state.readyPlayers shouldBeEqual setOf(player1, player2)
state.direction shouldBeEqual GameState.Direction.CLOCKWISE
assertNotNull(state.lastCard) shouldBeEqual GameState.LastCard(player2Hand.first(), player2)
}
}
})

View File

@@ -1,5 +1,6 @@
package eventDemo.app.query package eventDemo.app.query
import eventDemo.app.entity.GameId
import eventDemo.shared.GameIdSerializer
import eventDemo.shared.UUIDSerializer import eventDemo.shared.UUIDSerializer
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
@@ -17,6 +18,7 @@ fun ApplicationTestBuilder.httpClient(): HttpClient =
serializersModule = serializersModule =
SerializersModule { SerializersModule {
contextual(UUID::class) { UUIDSerializer } contextual(UUID::class) { UUIDSerializer }
contextual(GameId::class) { GameIdSerializer }
} }
}, },
) )

View File

@@ -5,7 +5,10 @@ import io.ktor.websocket.Frame
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
class CommandTest( class CommandTest(
override val id: CommandId, override val id: CommandId,
) : Command ) : Command
@@ -15,16 +18,12 @@ class CommandStreamChannelTest :
test("send and receive") { test("send and receive") {
val command = CommandTest(CommandId()) val command = CommandTest(CommandId())
val command2 = CommandTest(CommandId())
val command3 = CommandTest(CommandId())
val channel = Channel<Frame>() val channel = Channel<Frame>()
val stream = val stream =
CommandStreamChannel<CommandTest>( CommandStreamChannel<CommandTest>(
incoming = channel, incoming = channel,
outgoing = channel, deserializer = { Json.decodeFromString(it) },
serializer = { it.id.toString() },
deserializer = { CommandTest(CommandId(it)) },
) )
val spyCall: () -> Unit = mockk(relaxed = true) val spyCall: () -> Unit = mockk(relaxed = true)
@@ -33,8 +32,7 @@ class CommandStreamChannelTest :
println("In action ${it.id}") println("In action ${it.id}")
spyCall() spyCall()
} }
stream.send(command, command2) channel.send(Frame.Text(Json.encodeToString(command)))
stream.send(command3) verify(exactly = 1) { spyCall() }
verify(exactly = 3) { spyCall() }
} }
}) })