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-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$koin_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-datetime:0.6.2")
testImplementation("io.kotest:kotest-extensions-koin:6.0.0.M2")
testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
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_serialization_version=1.8.0
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=7.0.4
kotest_version=5.9.1

View File

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

View File

@@ -10,15 +10,13 @@ import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
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 kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
/**
* Listen [GameCommand] on [GameCommandStream], check the validity and execute an action.
@@ -28,26 +26,32 @@ import kotlinx.coroutines.launch
class GameCommandHandler(
private val eventStream: GameEventStream,
) {
private val logger = KotlinLogging.logger { }
/**
* Init the handler
*/
@OptIn(DelicateCoroutinesApi::class)
fun handle(
suspend fun handle(
player: Player,
incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
): Job {
val commandStream = GameCommandStream(incoming, outgoing)
val playerNotifier: (String) -> Unit = { outgoing.trySendBlocking(Frame.Text(it)) }
return GlobalScope.launch {
init(player, commandStream, playerNotifier)
) {
val commandStream = GameCommandStream(incoming)
val playerErrorNotifier: (String) -> Unit = {
val notification = ErrorNotification(message = it)
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(
player: Player,
commandStream: GameCommandStream,
playerNotifier: (String) -> Unit,
playerErrorNotifier: (String) -> Unit,
) {
commandStream.process { command ->
if (command.payload.player.id != player.id) {
@@ -57,12 +61,12 @@ class GameCommandHandler(
val gameState = command.buildGameState()
when (command) {
is IWantToPlayCardCommand -> command.run(gameState, playerNotifier, eventStream)
is IamReadyToPlayCommand -> command.run(gameState, playerNotifier, eventStream)
is IWantToJoinTheGameCommand -> command.run(gameState, playerNotifier, eventStream)
is ICantPlayCommand -> command.run(gameState, playerNotifier, eventStream)
is IWantToPlayCardCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IamReadyToPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is IWantToJoinTheGameCommand -> command.run(gameState, playerErrorNotifier, eventStream)
is ICantPlayCommand -> command.run(gameState, playerErrorNotifier, eventStream)
}
} as CommandBlock<GameCommand>
}
}
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.routing.Route
import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@DelicateCoroutinesApi
fun Route.gameSocket(
playerNotificationListener: GameEventPlayerNotificationListener,
commandHandler: GameCommandHandler,
) {
authenticate {
webSocket("/game") {
commandHandler.handle(call.getPlayer(), incoming, outgoing)
playerNotificationListener.startListening(outgoing)
val currentPlayer = call.getPlayer()
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 io.ktor.websocket.Frame
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.serialization.json.Json
/**
@@ -19,10 +18,7 @@ class GameCommandStreamInMemory : CommandStreamInMemory<GameCommand>()
*/
class GameCommandStream(
incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
) : CommandStream<GameCommand> by CommandStreamChannel(
incoming,
outgoing,
{ Json.encodeToString(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.event.PlayerHavePassEvent
import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
@SerialName("Pass")
data class ICantPlayCommand(
override val payload: Payload,
) : GameCommand {
@@ -27,19 +25,22 @@ data class ICantPlayCommand(
fun run(
state: GameState,
playerNotifier: (String) -> Unit,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
) {
val playableCards = state.playableCards(payload.player)
if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first()
eventStream.publish(
PlayerHavePassEvent(
payload.gameId,
payload.player,
gameId = payload.gameId,
player = payload.player,
takenCard = takenCard,
),
)
} 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.event.NewPlayerEvent
import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
@SerialName("JoinGame")
data class IWantToJoinTheGameCommand(
override val payload: Payload,
) : GameCommand {
@@ -27,9 +26,10 @@ data class IWantToJoinTheGameCommand(
fun run(
state: GameState,
playerNotifier: (String) -> Unit,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
) {
val logger = KotlinLogging.logger {}
if (!state.isStarted) {
eventStream.publish(
NewPlayerEvent(
@@ -38,7 +38,11 @@ data class IWantToJoinTheGameCommand(
),
)
} 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.event.CardIsPlayedEvent
import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
@SerialName("PlayCard")
data class IWantToPlayCardCommand(
override val payload: Payload,
) : GameCommand {
@@ -29,11 +27,11 @@ data class IWantToPlayCardCommand(
fun run(
state: GameState,
playerNotifier: (String) -> Unit,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
) {
if (!state.isReady) {
playerNotifier("The game is Not started")
if (!state.isStarted) {
playerErrorNotifier("The game is Not started")
return
}
@@ -46,7 +44,7 @@ data class IWantToPlayCardCommand(
),
)
} 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.event.PlayerReadyEvent
import eventDemo.libs.command.CommandId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A command to set as ready to play
*/
@Serializable
@SerialName("Ready")
data class IamReadyToPlayCommand(
override val payload: Payload,
) : GameCommand {
@@ -27,13 +25,16 @@ data class IamReadyToPlayCommand(
fun run(
state: GameState,
playerNotifier: (String) -> Unit,
playerErrorNotifier: (String) -> Unit,
eventStream: GameEventStream,
) {
val playerExist: Boolean = state.players.contains(payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player)
if (playerIsAlreadyReady) {
playerNotifier("You are already ready")
if (!playerExist) {
playerErrorNotifier("You are not in the game")
} else if (playerIsAlreadyReady) {
playerErrorNotifier("You are already ready")
} else {
eventStream.publish(
PlayerReadyEvent(

View File

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

View File

@@ -12,4 +12,6 @@ import java.util.UUID
@Serializable(with = GameIdSerializer::class)
value class GameId(
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)),
)
@Serializable
@JvmInline
value class PlayerId(
@Serializable(with = UUIDSerializer::class)

View File

@@ -5,28 +5,38 @@ import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class PlayersHands(
private val map: Map<Player, List<Card>> = emptyMap(),
) : Map<Player, List<Card>> by map {
constructor(players: Set<Player>) : this(players.associateWith { emptyList<Card>() }.toPlayersHands())
private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
) : Map<Player.PlayerId, List<Card>> by map {
constructor(players: Set<Player>) :
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
fun getHand(player: Player): List<Card>? = this[player.id]
fun removeCard(
player: Player,
card: Card,
): PlayersHands =
mapValues { (p, cards) ->
if (p == player) {
mapValues { (playerId, cards) ->
if (playerId == player.id) {
if (!cards.contains(card)) error("The hand no contain the card")
cards - card
} else {
cards
}
}.toPlayersHands()
fun addCard(
player: Player,
newCard: Card,
): PlayersHands = addCards(player, listOf(newCard))
fun addCards(
player: Player,
newCards: List<Card>,
): PlayersHands =
mapValues { (p, cards) ->
if (p == player) {
if (p == player.id) {
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
cards + newCards
} else {
cards
@@ -34,4 +44,4 @@ value class PlayersHands(
}.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.PlayerHavePassEvent
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(
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 =
events.fold(GameState(this)) { state: GameState, event: GameEvent ->
when (event) {
@@ -37,12 +41,13 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
lastPlayer = event.player,
direction = direction,
lastColor = color,
lastCard = GameState.LastCard(event.card, event.player),
deck = state.deck.putOneCardFromHand(event.player, event.card),
)
}
is NewPlayerEvent -> {
if (state.isReady) error("The game is already started")
if (state.isStarted) error("The game is already started")
state.copy(
players = state.players + event.player,
@@ -56,6 +61,7 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
}
is PlayerHavePassEvent -> {
if (event.takenCard != state.deck.stack.first()) error("taken card is not ot top of the stack")
state.copy(
lastPlayer = event.player,
deck = state.deck.takeOneCardFromStackTo(event.player),
@@ -70,7 +76,7 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
is GameStartedEvent -> {
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),
lastPlayer = event.firstPlayer,
deck = event.deck,

View File

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

View File

@@ -9,5 +9,5 @@ import kotlinx.serialization.Serializable
*/
@Serializable
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.
*/
data class GameStartedEvent(
override val id: GameId,
override val gameId: GameId,
val firstPlayer: Player,
val deck: Deck,
) : GameEvent {
@@ -19,9 +19,20 @@ data class GameStartedEvent(
players: Set<Player>,
): GameStartedEvent =
GameStartedEvent(
id = id,
firstPlayer = players.random(),
deck = Deck.newWithoutPlayers().initHands(players).placeFirstCardOnDiscard(),
gameId = id,
firstPlayer = if (isDisabled) players.first() else players.random(),
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.
*/
data class NewPlayerEvent(
override val id: GameId,
override val gameId: GameId,
val player: Player,
) : GameEvent

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,126 @@
package eventDemo.app.eventListener
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventBus
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStreamTo
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent
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 io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking
class GameEventPlayerNotificationListener(
private val eventBus: GameEventBus,
private val eventStream: GameEventStream,
) {
fun startListening(outgoing: SendChannel<Frame>) {
private val logger = KotlinLogging.logger {}
fun startListening(
outgoing: SendChannel<Frame>,
currentPlayer: Player,
) {
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,24 +2,40 @@ package eventDemo.app.eventListener
import eventDemo.app.event.GameEventBus
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.GameStartedEvent
import io.github.oshai.kotlinlogging.KotlinLogging
class GameEventReactionListener(
private val eventBus: GameEventBus,
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() {
eventBus.subscribe { event: GameEvent ->
val state = event.id.buildStateFromEventStream(eventStream)
if (state.isReady) {
eventStream.publish(
eventBus.subscribe(priority) { event: GameEvent ->
val state = event.buildStateFromEventStreamTo(eventStream)
if (state.isReady && !state.isStarted) {
val reactionEvent =
GameStartedEvent.new(
state.gameId,
state.players,
),
)
)
logger.atInfo {
message = "Event Send on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
}
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
import eventDemo.app.eventListener.GameEventReactionListener
import io.ktor.server.application.Application
import org.koin.ktor.ext.get
@@ -17,6 +16,5 @@ fun Application.configure() {
configureHttpRouting()
declareHttpGameRoute()
GameEventReactionListener(get(), get())
.init()
configureGameListener()
}

View File

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

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
import eventDemo.app.entity.GameId
import eventDemo.shared.GameIdSerializer
import eventDemo.shared.UUIDSerializer
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
@@ -16,6 +18,7 @@ fun Application.configureSerialization() {
serializersModule =
SerializersModule {
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 io.ktor.server.application.Application
import io.ktor.server.routing.routing
import kotlinx.coroutines.DelicateCoroutinesApi
@OptIn(DelicateCoroutinesApi::class)
fun Application.declareWebSocketsGameRoute(
playerNotificationListener: GameEventPlayerNotificationListener,
commandHandler: GameCommandHandler,

View File

@@ -3,7 +3,6 @@ package eventDemo.libs.command
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.reflect.KClass
/**
* Represent a Command stream.
@@ -11,24 +10,6 @@ import kotlin.reflect.KClass
* The stream contains a list of all actions yet to be executed.
*/
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.
*/
@@ -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.readText
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
*/
class CommandStreamChannel<C : Command>(
private val incoming: ReceiveChannel<Frame>,
private val outgoing: SendChannel<Frame>,
private val serializer: (C) -> String,
private val deserializer: (String) -> C,
) : CommandStream<C> {
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>) {
// incoming.consumeEach { commandAsFrame ->
// if (commandAsFrame is Frame.Text) {
@@ -90,17 +61,15 @@ class CommandStreamChannel<C : Command>(
private suspend fun markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack"
message = "Compute command SUCCESS: $command"
payload = mapOf("command" to command)
}
// outgoing.trySendBlocking(Frame.Text("Command executed successfully"))
}
private suspend fun markAsFailed(command: C) {
logger.atWarn {
message = "Compute command FAILED"
message = "Compute command FAILED: $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 kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.channels.trySendBlocking
import kotlin.reflect.KClass
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" }
})
/**
* 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>) {
queue.consumeEach { command ->
compute(command, action)
@@ -71,14 +55,14 @@ abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
private fun <C : Command> markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack : $command"
message = "Compute command SUCCESS : $command"
payload = mapOf("command" to command)
}
}
private fun <C : Command> markAsFailed(command: C) {
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)
}
}

View File

@@ -15,5 +15,5 @@ interface AggregateId {
* @see EventStream
*/
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> {
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
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) {
subscribers.forEach {
it(event)
}
subscribers
.sortedByDescending { (priority, block) -> priority }
.forEach { (_, block) ->
block(event)
}
}
override fun subscribe(block: (E) -> Unit) {
subscribers.add(block)
override fun subscribe(
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? =
events
.filterIsInstance(eventType.java)
.lastOrNull { it.id == aggregateId }
.lastOrNull { it.gameId == aggregateId }
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.event.event.GameEvent
import eventDemo.app.notification.Notification
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
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 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())
}
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING)
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PlayerId", PrimitiveKind.STRING)
}
object GameIdSerializer : KSerializer<GameId> {

View File

@@ -1,8 +1,14 @@
package eventDemo
import eventDemo.app.command.command.GameCommand
import eventDemo.app.entity.Card
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.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.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) - 1
modifiedDeck.playersHands.size shouldBeExactly playerNumbers
assertNotNull(modifiedDeck.playersHands[firstPlayer]).size shouldBeExactly 7 + 1
assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 7 + 1
modifiedDeck.playersHands
.filterKeys { it != firstPlayer }
.filterKeys { it != firstPlayer.id }
.forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
}
@@ -70,16 +70,16 @@ class DeckTest :
val firstPlayer = players.first()
// When
val card = deck.playersHands[firstPlayer]!!.first()
val card = deck.playersHands.getHand(firstPlayer)!!.first()
val modifiedDeck = deck.putOneCardFromHand(firstPlayer, card)
// Then
modifiedDeck.discard.size shouldBeExactly 1
modifiedDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7)
modifiedDeck.playersHands.size shouldBeExactly playerNumbers
assertNotNull(modifiedDeck.playersHands[firstPlayer]).size shouldBeExactly 6
assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 6
modifiedDeck.playersHands
.filterKeys { it != firstPlayer }
.filterKeys { it != firstPlayer.id }
.forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
}

View File

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

View File

@@ -1,49 +1,50 @@
package eventDemo.app.query
import eventDemo.app.GameState
import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.configuration.configure
import eventDemo.configuration.makeJwt
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.request.HttpRequestBuilder
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.header
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.contentType
import io.ktor.server.testing.testApplication
import org.koin.core.context.stopKoin
import org.koin.java.KoinJavaComponent.getKoin
import org.koin.ktor.ext.inject
import kotlin.test.assertEquals
class CardTest :
class GameStateRouteTest :
FunSpec({
test("/game/{id}/card") {
test("/game/{id}/state on empty game") {
testApplication {
val id = GameId()
val player1 = Player(name = "Nikola")
application {
stopKoin()
configure()
}
val id = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue)
val player = Player(name = "Nikola")
httpClient()
.post("/game/$id/card") {
contentType(Json)
accept(Json)
setBody(card)
.get("/game/$id/state") {
withAuth(player1)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val eventStream = getKoin().get<GameEventStream>()
assertEquals(CardIsPlayedEvent(id, card, player), eventStream.readLast(id))
val state = call.body<GameState>()
assertEquals(id, state.gameId)
state.players shouldHaveSize 0
state.isStarted shouldBeEqual false
}
}
}
@@ -52,12 +53,13 @@ class CardTest :
testApplication {
val id = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue)
val player = Player(name = "Nikola")
application {
stopKoin()
configure()
val eventStream by inject<GameEventStream>()
val player = Player(name = "Nikola")
eventStream.publish(
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player),
CardIsPlayedEvent(id, card, player),
@@ -66,10 +68,18 @@ class CardTest :
)
}
httpClient().get("/game/$id/card/last").apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
assertEquals(card, call.body<Card>())
}
httpClient()
.get("/game/$id/card/last") {
withAuth(player)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
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
import eventDemo.app.entity.GameId
import eventDemo.shared.GameIdSerializer
import eventDemo.shared.UUIDSerializer
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
@@ -17,6 +18,7 @@ fun ApplicationTestBuilder.httpClient(): HttpClient =
serializersModule =
SerializersModule {
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.verify
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
class CommandTest(
override val id: CommandId,
) : Command
@@ -15,16 +18,12 @@ class CommandStreamChannelTest :
test("send and receive") {
val command = CommandTest(CommandId())
val command2 = CommandTest(CommandId())
val command3 = CommandTest(CommandId())
val channel = Channel<Frame>()
val stream =
CommandStreamChannel<CommandTest>(
incoming = channel,
outgoing = channel,
serializer = { it.id.toString() },
deserializer = { CommandTest(CommandId(it)) },
deserializer = { Json.decodeFromString(it) },
)
val spyCall: () -> Unit = mockk(relaxed = true)
@@ -33,8 +32,7 @@ class CommandStreamChannelTest :
println("In action ${it.id}")
spyCall()
}
stream.send(command, command2)
stream.send(command3)
verify(exactly = 3) { spyCall() }
channel.send(Frame.Text(Json.encodeToString(command)))
verify(exactly = 1) { spyCall() }
}
})