update ktlint rules

This commit is contained in:
2025-03-14 03:23:16 +01:00
parent 492981bed0
commit b4234a9b37
97 changed files with 2392 additions and 2359 deletions

View File

@@ -4,3 +4,5 @@ ktlint_standard = enabled
ktlint_experimental = enabled ktlint_experimental = enabled
ktlint_standard_string-template-indent = enabled ktlint_standard_string-template-indent = enabled
ktlint_standard_multiline-expression-wrapping = enabled ktlint_standard_multiline-expression-wrapping = enabled
ktlint_function_signature_body_expression_wrapping = always
indent_size = 2

View File

@@ -1,6 +1,5 @@
@file:Suppress("PropertyName") @file:Suppress("PropertyName")
@Suppress("ktlint:standard:property-naming")
val ktor_version: String by project val ktor_version: String by project
val kotlin_version: String by project val kotlin_version: String by project
val kotlin_serialization_version: String by project val kotlin_serialization_version: String by project
@@ -10,64 +9,63 @@ val kotlin_logging_version: String by project
val kotest_version: String by project val kotest_version: String by project
plugins { plugins {
kotlin("jvm") version "2.1.10" kotlin("jvm") version "2.1.10"
id("io.ktor.plugin") version "2.3.13" id("io.ktor.plugin") version "2.3.13"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
id("org.jlleitschuh.gradle.ktlint") version "12.2.0" id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
} }
group = "io.github.flecomte" group = "io.github.flecomte"
version = "0.0.1" version = "0.0.1"
application { application {
mainClass.set("eventDemo.ApplicationKt") mainClass.set("eventDemo.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development") val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
} }
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> { configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
version.set("1.5.0") version.set("1.5.0")
enableExperimentalRules.set(true)
} }
repositories { repositories {
mavenCentral() mavenCentral()
} }
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnitPlatform() useJUnitPlatform()
} }
dependencies { dependencies {
implementation("io.ktor:ktor-server-core-jvm") implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-jvm") implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm") implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-auto-head-response-jvm") implementation("io.ktor:ktor-server-auto-head-response-jvm")
implementation("io.ktor:ktor-server-resources") implementation("io.ktor:ktor-server-resources")
implementation("io.ktor:ktor-server-content-negotiation-jvm") implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-websockets-jvm") implementation("io.ktor:ktor-server-websockets-jvm")
implementation("io.ktor:ktor-server-cors-jvm") implementation("io.ktor:ktor-server-cors-jvm")
implementation("io.ktor:ktor-server-host-common-jvm") implementation("io.ktor:ktor-server-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm") implementation("io.ktor:ktor-server-status-pages-jvm")
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("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") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
// Force version of sub library (for security) // Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13") implementation("commons-codec:commons-codec:1.13")
testImplementation("io.kotest:kotest-extensions-koin:$kotest_version") testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
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")
testImplementation("io.kotest:kotest-runner-junit5:$kotest_version") testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
testImplementation("io.mockk:mockk:1.13.17") testImplementation("io.mockk:mockk:1.13.17")
} }

View File

@@ -6,11 +6,11 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
fun main() { fun main() {
embeddedServer( embeddedServer(
factory = Netty, factory = Netty,
port = 8080, port = 8080,
host = "0.0.0.0", host = "0.0.0.0",
module = Application::configure, module = Application::configure,
watchPaths = listOf("classes"), watchPaths = listOf("classes"),
).start(wait = true) ).start(wait = true)
} }

View File

@@ -9,21 +9,21 @@ import kotlinx.coroutines.channels.SendChannel
typealias ErrorNotifier = suspend (String) -> Unit typealias ErrorNotifier = suspend (String) -> Unit
fun errorNotifier( fun errorNotifier(
command: GameCommand, command: GameCommand,
channel: SendChannel<Notification>, channel: SendChannel<Notification>,
): ErrorNotifier = ): ErrorNotifier =
{ {
val logger = KotlinLogging.logger { } val logger = KotlinLogging.logger { }
ErrorNotification(message = it) ErrorNotification(message = it)
.let { notification -> .let { notification ->
logger.atWarn { logger.atWarn {
message = "Notification ERROR sent: ${notification.message}" message = "Notification ERROR sent: ${notification.message}"
payload = payload =
mapOf( mapOf(
"notification" to notification, "notification" to notification,
"command" to command, "command" to command,
) )
} }
channel.send(notification) channel.send(notification)
} }
} }

View File

@@ -15,32 +15,33 @@ import kotlinx.coroutines.channels.SendChannel
* This action can be executing an action and produce a new [GameEvent] after verification. * This action can be executing an action and produce a new [GameEvent] after verification.
*/ */
class GameCommandHandler( class GameCommandHandler(
private val commandStreamChannel: CommandStreamChannelBuilder<GameCommand>, private val commandStreamChannel: CommandStreamChannelBuilder<GameCommand>,
private val runner: GameCommandRunner, private val runner: GameCommandRunner,
) { ) {
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
/** /**
* Init the handler * Init the handler
*/ */
suspend fun handle( suspend fun handle(
player: Player, player: Player,
incomingCommandChannel: ReceiveChannel<GameCommand>, incomingCommandChannel: ReceiveChannel<GameCommand>,
outgoingErrorChannelNotification: SendChannel<Notification>, outgoingErrorChannelNotification: SendChannel<Notification>,
) = commandStreamChannel(incomingCommandChannel) ) =
.process { command -> commandStreamChannel(incomingCommandChannel)
if (command.payload.player.id != player.id) { .process { command ->
logger.atWarn { if (command.payload.player.id != player.id) {
message = "Handle command Refuse, the player of the command is not the same: $command" logger.atWarn {
payload = mapOf("command" to command) message = "Handle command Refuse, the player of the command is not the same: $command"
} payload = mapOf("command" to command)
nack() }
} else { nack()
logger.atInfo { } else {
message = "Handle command: $command" logger.atInfo {
payload = mapOf("command" to command) message = "Handle command: $command"
} payload = mapOf("command" to command)
runner.run(command, outgoingErrorChannelNotification) }
} runner.run(command, outgoingErrorChannelNotification)
} }
}
} }

View File

@@ -18,29 +18,29 @@ import kotlinx.coroutines.launch
@DelicateCoroutinesApi @DelicateCoroutinesApi
fun Route.gameSocket( fun Route.gameSocket(
playerNotificationListener: PlayerNotificationEventListener, playerNotificationListener: PlayerNotificationEventListener,
commandHandler: GameCommandHandler, commandHandler: GameCommandHandler,
) { ) {
authenticate { authenticate {
webSocket("/game") { webSocket("/game") {
val currentPlayer = call.getPlayer() val currentPlayer = call.getPlayer()
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing) val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
GlobalScope.launch { GlobalScope.launch {
commandHandler.handle( commandHandler.handle(
currentPlayer, currentPlayer,
toObjectChannel(incoming), toObjectChannel(incoming),
outgoingFrameChannel, outgoingFrameChannel,
) )
} }
playerNotificationListener.startListening(outgoingFrameChannel, currentPlayer) playerNotificationListener.startListening(outgoingFrameChannel, currentPlayer)
}
} }
}
} }
private fun ApplicationCall.getPlayer() = private fun ApplicationCall.getPlayer() =
principal<JWTPrincipal>()!!.run { principal<JWTPrincipal>()!!.run {
Player( Player(
id = payload.getClaim("playerid").asString(), id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(), name = payload.getClaim("username").asString(),
) )
} }

View File

@@ -11,21 +11,21 @@ import eventDemo.app.notification.Notification
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
class GameCommandRunner( class GameCommandRunner(
private val eventHandler: GameEventHandler, private val eventHandler: GameEventHandler,
private val gameStateRepository: GameStateRepository, private val gameStateRepository: GameStateRepository,
) { ) {
suspend fun run( suspend fun run(
command: GameCommand, command: GameCommand,
outgoingErrorChannelNotification: SendChannel<Notification>, outgoingErrorChannelNotification: SendChannel<Notification>,
) { ) {
val gameState = gameStateRepository.getLast(command.payload.aggregateId) val gameState = gameStateRepository.getLast(command.payload.aggregateId)
val errorNotifier = errorNotifier(command, outgoingErrorChannelNotification) val errorNotifier = errorNotifier(command, outgoingErrorChannelNotification)
when (command) { when (command) {
is IWantToPlayCardCommand -> command.run(gameState, errorNotifier, this.eventHandler) is IWantToPlayCardCommand -> command.run(gameState, errorNotifier, this.eventHandler)
is IamReadyToPlayCommand -> command.run(gameState, errorNotifier, this.eventHandler) is IamReadyToPlayCommand -> command.run(gameState, errorNotifier, this.eventHandler)
is IWantToJoinTheGameCommand -> command.run(gameState, errorNotifier, this.eventHandler) is IWantToJoinTheGameCommand -> command.run(gameState, errorNotifier, this.eventHandler)
is ICantPlayCommand -> command.run(gameState, errorNotifier, this.eventHandler) is ICantPlayCommand -> command.run(gameState, errorNotifier, this.eventHandler)
}
} }
}
} }

View File

@@ -9,5 +9,5 @@ import kotlinx.coroutines.channels.ReceiveChannel
* A stream to publish and read the game command. * A stream to publish and read the game command.
*/ */
class GameCommandStream( class GameCommandStream(
incoming: ReceiveChannel<GameCommand>, incoming: ReceiveChannel<GameCommand>,
) : CommandStream<GameCommand> by CommandStreamChannel(incoming) ) : CommandStream<GameCommand> by CommandStreamChannel(incoming)

View File

@@ -7,11 +7,11 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
sealed interface GameCommand : Command { sealed interface GameCommand : Command {
val payload: Payload val payload: Payload
@Serializable @Serializable
sealed interface Payload { sealed interface Payload {
val aggregateId: GameId val aggregateId: GameId
val player: Player val player: Player
} }
} }

View File

@@ -14,39 +14,39 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class ICantPlayCommand( data class ICantPlayCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
override val aggregateId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
suspend fun run( suspend fun run(
state: GameState, state: GameState,
playerErrorNotifier: ErrorNotifier, playerErrorNotifier: ErrorNotifier,
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
) { ) {
if (state.currentPlayerTurn != payload.player) { if (state.currentPlayerTurn != payload.player) {
playerErrorNotifier("Its not your turn!") playerErrorNotifier("Its not your turn!")
return return
}
val playableCards = state.playableCards(payload.player)
if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first()
eventHandler.handle(payload.aggregateId) {
PlayerHavePassEvent(
aggregateId = payload.aggregateId,
player = payload.player,
takenCard = takenCard,
version = it,
)
}
} else {
playerErrorNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}")
}
} }
val playableCards = state.playableCards(payload.player)
if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first()
eventHandler.handle(payload.aggregateId) {
PlayerHavePassEvent(
aggregateId = payload.aggregateId,
player = payload.player,
takenCard = takenCard,
version = it,
)
}
} else {
playerErrorNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}")
}
}
} }

View File

@@ -14,31 +14,31 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class IWantToJoinTheGameCommand( data class IWantToJoinTheGameCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
override val aggregateId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
suspend fun run( suspend fun run(
state: GameState, state: GameState,
playerErrorNotifier: ErrorNotifier, playerErrorNotifier: ErrorNotifier,
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
) { ) {
if (!state.isStarted) { if (!state.isStarted) {
eventHandler.handle(payload.aggregateId) { eventHandler.handle(payload.aggregateId) {
NewPlayerEvent( NewPlayerEvent(
aggregateId = payload.aggregateId, aggregateId = payload.aggregateId,
player = payload.player, player = payload.player,
version = it, version = it,
) )
} }
} else { } else {
playerErrorNotifier("The game is already started") playerErrorNotifier("The game is already started")
}
} }
}
} }

View File

@@ -15,42 +15,42 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class IWantToPlayCardCommand( data class IWantToPlayCardCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
override val aggregateId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
val card: Card, val card: Card,
) : GameCommand.Payload ) : GameCommand.Payload
suspend fun run( suspend fun run(
state: GameState, state: GameState,
playerErrorNotifier: ErrorNotifier, playerErrorNotifier: ErrorNotifier,
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
) { ) {
if (!state.isStarted) { if (!state.isStarted) {
playerErrorNotifier("The game is Not started") playerErrorNotifier("The game is Not started")
return return
}
if (state.currentPlayerTurn != payload.player) {
playerErrorNotifier("Its not your turn!")
return
}
if (state.canBePlayThisCard(payload.player, payload.card)) {
eventHandler.handle(payload.aggregateId) {
CardIsPlayedEvent(
aggregateId = payload.aggregateId,
card = payload.card,
player = payload.player,
version = it,
)
}
} else {
playerErrorNotifier("You cannot play this card")
}
} }
if (state.currentPlayerTurn != payload.player) {
playerErrorNotifier("Its not your turn!")
return
}
if (state.canBePlayThisCard(payload.player, payload.card)) {
eventHandler.handle(payload.aggregateId) {
CardIsPlayedEvent(
aggregateId = payload.aggregateId,
card = payload.card,
player = payload.player,
version = it,
)
}
} else {
playerErrorNotifier("You cannot play this card")
}
}
} }

View File

@@ -14,38 +14,38 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class IamReadyToPlayCommand( data class IamReadyToPlayCommand(
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
override val aggregateId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
suspend fun run( suspend fun run(
state: GameState, state: GameState,
playerErrorNotifier: ErrorNotifier, playerErrorNotifier: ErrorNotifier,
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
) { ) {
val playerExist: Boolean = state.players.contains(payload.player) val playerExist: Boolean = state.players.contains(payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player) val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player)
if (state.isStarted) { if (state.isStarted) {
playerErrorNotifier("The game is already started") playerErrorNotifier("The game is already started")
} else if (!playerExist) { } else if (!playerExist) {
playerErrorNotifier("You are not in the game") playerErrorNotifier("You are not in the game")
} else if (playerIsAlreadyReady) { } else if (playerIsAlreadyReady) {
playerErrorNotifier("You are already ready") playerErrorNotifier("You are already ready")
} else { } else {
eventHandler.handle(payload.aggregateId) { eventHandler.handle(payload.aggregateId) {
PlayerReadyEvent( PlayerReadyEvent(
aggregateId = payload.aggregateId, aggregateId = payload.aggregateId,
player = payload.player, player = payload.player,
version = it, version = it,
) )
} }
}
} }
}
} }

View File

@@ -10,100 +10,100 @@ import java.util.UUID
*/ */
@Serializable @Serializable
sealed interface Card { sealed interface Card {
val id: UUID val id: UUID
/** /**
* The color of a card * The color of a card
*/ */
@Serializable @Serializable
enum class Color { enum class Color {
Blue, Blue,
Red, Red,
Yellow, Yellow,
Green, Green,
} }
sealed interface ColorCard : Card { sealed interface ColorCard : Card {
val color: Color val color: Color
} }
/** /**
* A play card with color and number * A play card with color and number
*/ */
@Serializable @Serializable
@SerialName("Simple") @SerialName("Simple")
data class NumericCard( data class NumericCard(
val number: Int, val number: Int,
override val color: Color, override val color: Color,
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Card, ) : Card,
ColorCard ColorCard
sealed interface Special : Card sealed interface Special : Card
/** /**
* A revert card to revert the order of the turn. * A revert card to revert the order of the turn.
*/ */
@Serializable @Serializable
@SerialName("Reverse") @SerialName("Reverse")
data class ReverseCard( data class ReverseCard(
override val color: Color, override val color: Color,
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
ColorCard ColorCard
sealed interface PassTurnCard : Card sealed interface PassTurnCard : Card
/** /**
* A pass card to pass the turn of the next player. * A pass card to pass the turn of the next player.
*/ */
@Serializable @Serializable
@SerialName("Pass") @SerialName("Pass")
data class PassCard( data class PassCard(
override val color: Color, override val color: Color,
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
ColorCard, ColorCard,
PassTurnCard PassTurnCard
/** /**
* A play card to force the next player to take 2 card and pass the turn. * A play card to force the next player to take 2 card and pass the turn.
*/ */
@Serializable @Serializable
@SerialName("Plus2") @SerialName("Plus2")
data class Plus2Card( data class Plus2Card(
override val color: Color, override val color: Color,
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
ColorCard, ColorCard,
PassTurnCard PassTurnCard
sealed interface AllColorCard : Card sealed interface AllColorCard : Card
/** /**
* A play card to force the next player to take 4 card and pass the turn. * A play card to force the next player to take 4 card and pass the turn.
*/ */
@Serializable @Serializable
@SerialName("Plus4") @SerialName("Plus4")
class Plus4Card( class Plus4Card(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
AllColorCard, AllColorCard,
PassTurnCard PassTurnCard
/** /**
* A play card to change the color. * A play card to change the color.
*/ */
@Serializable @Serializable
@SerialName("ChangeColor") @SerialName("ChangeColor")
class ChangeColorCard( class ChangeColorCard(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
AllColorCard AllColorCard
} }

View File

@@ -4,119 +4,130 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Deck( data class Deck(
val stack: Stack = Stack(), val stack: Stack = Stack(),
val discard: Discard = Discard(), val discard: Discard = Discard(),
val playersHands: PlayersHands = PlayersHands(), val playersHands: PlayersHands = PlayersHands(),
) { ) {
constructor(players: Set<Player>) : constructor(players: Set<Player>) :
this(playersHands = PlayersHands(players)) this(playersHands = PlayersHands(players))
fun shuffle(): Deck = copy(stack = stack.shuffle()) 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(
stack = stack - takenCard, stack = stack - takenCard,
discard = discard + takenCard, discard = discard + takenCard,
) )
}
fun takeOneCardFromStackTo(player: Player): Deck =
takeOne().let { (deck, newPlayerCard) ->
deck.copy(
playersHands = deck.playersHands.addCard(player, newPlayerCard),
)
} }
fun takeOneCardFromStackTo(player: Player): Deck = fun putOneCardFromHand(
takeOne().let { (deck, newPlayerCard) -> player: Player,
deck.copy( card: Card,
playersHands = deck.playersHands.addCard(player, newPlayerCard), ): Deck =
) run {
} // Validate parameters
val playerHand =
playersHands.getHand(player)
?: error("No player on this game")
if (playerHand.none { it == card }) {
error("No card exist on the player hand")
}
}.let {
copy(
discard = discard + card,
playersHands = playersHands.removeCard(player, card),
)
}
fun putOneCardFromHand( fun playerHasNoCardLeft(): List<Player.PlayerId> =
player: Player, playersHands
card: Card, .filter { (playerId, hand) -> hand.isEmpty() }
): Deck = .map { (playerId, hand) -> playerId }
run {
// Validate parameters private fun take(n: Int): Pair<Deck, List<Card>> {
val playerHand = val takenCards = stack.take(n)
playersHands.getHand(player) val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
?: error("No player on this game") return Pair(copy(stack = newStack), takenCards)
if (playerHand.none { it == card }) { }
error("No card exist on the player hand")
} private fun takeOne(): Pair<Deck, Card> =
take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
companion object {
fun newWithoutPlayers(): Deck =
listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green)
.flatMap { color ->
((0..9) + (1..9)).map { Card.NumericCard(it, color) } +
(1..2).map { Card.Plus2Card(color) } +
(1..2).map { Card.ReverseCard(color) } +
(1..2).map { Card.PassCard(color) }
}.let { }.let {
copy( it + (1..4).map { Card.Plus4Card() }
discard = discard + card, }.toStack()
playersHands = playersHands.removeCard(player, card), .let { Deck(it) }
) }
}
fun playerHasNoCardLeft(): List<Player.PlayerId> =
playersHands
.filter { (playerId, hand) -> hand.isEmpty() }
.map { (playerId, hand) -> playerId }
private fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
return Pair(copy(stack = newStack), takenCards)
}
private fun takeOne(): Pair<Deck, Card> = take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
companion object {
fun newWithoutPlayers(): Deck =
listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green)
.flatMap { color ->
((0..9) + (1..9)).map { Card.NumericCard(it, color) } +
(1..2).map { Card.Plus2Card(color) } +
(1..2).map { Card.ReverseCard(color) } +
(1..2).map { Card.PassCard(color) }
}.let {
it + (1..4).map { Card.Plus4Card() }
}.toStack()
.let { Deck(it) }
}
} }
fun Deck.initHands( fun Deck.initHands(
players: Set<Player>, players: Set<Player>,
handSize: Int = 7, handSize: Int = 7,
): Deck { ): Deck {
// Copy cards from stack to the player hands // Copy cards from stack to the player hands
val deckWithEmptyHands = copy(playersHands = PlayersHands(players)) val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
return players.fold(deckWithEmptyHands) { acc: Deck, player: Player -> return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
val hand = acc.stack.take(handSize) val hand = acc.stack.take(handSize)
val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack() val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
copy( copy(
stack = newStack, stack = newStack,
playersHands = acc.playersHands.addCards(player, hand), playersHands = acc.playersHands.addCards(player, hand),
) )
} }
} }
@JvmInline @JvmInline
@Serializable @Serializable
value class Stack( value class Stack(
private val cards: Set<Card> = emptySet(), private val cards: Set<Card> = emptySet(),
) : Set<Card> by cards { ) : Set<Card> by cards {
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 shuffle(): Stack =
shuffled().toStack()
} }
fun List<Card>.toStack(): Stack = Stack(this.toSet()) fun List<Card>.toStack(): Stack =
Stack(this.toSet())
fun Set<Card>.toStack(): Stack = Stack(this) fun Set<Card>.toStack(): Stack =
Stack(this)
@JvmInline @JvmInline
@Serializable @Serializable
value class Discard( value class Discard(
private val cards: Set<Card> = emptySet(), private val cards: Set<Card> = emptySet(),
) : Set<Card> by cards { ) : Set<Card> by cards {
operator fun plus(card: Card): Discard = cards.plus(card).toDiscard() operator fun plus(card: Card): Discard =
cards.plus(card).toDiscard()
operator fun minus(card: Card): Discard = cards.minus(card).toDiscard() operator fun minus(card: Card): Discard =
cards.minus(card).toDiscard()
} }
fun List<Card>.toDiscard(): Discard = Discard(this.toSet()) fun List<Card>.toDiscard(): Discard =
Discard(this.toSet())
fun Set<Card>.toDiscard(): Discard = Discard(this) fun Set<Card>.toDiscard(): Discard =
Discard(this)

View File

@@ -11,7 +11,8 @@ import java.util.UUID
@JvmInline @JvmInline
@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() override fun toString(): String =
id.toString()
} }

View File

@@ -8,21 +8,22 @@ import java.util.UUID
@Serializable @Serializable
data class Player( data class Player(
val name: String, val name: String,
@Serializable(with = PlayerIdSerializer::class) @Serializable(with = PlayerIdSerializer::class)
val id: PlayerId = PlayerId(UUID.randomUUID()), val id: PlayerId = PlayerId(UUID.randomUUID()),
) { ) {
constructor(id: String, name: String) : this( constructor(id: String, name: String) : this(
name, name,
PlayerId(UUID.fromString(id)), PlayerId(UUID.fromString(id)),
) )
@Serializable @Serializable
@JvmInline @JvmInline
value class PlayerId( value class PlayerId(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId { ) : AggregateId {
override fun toString(): String = id.toString() override fun toString(): String =
} id.toString()
}
} }

View File

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

View File

@@ -7,10 +7,10 @@ import eventDemo.libs.event.Event
* A stream to publish and read the played card event. * A stream to publish and read the played card event.
*/ */
interface EventHandler<E : Event<ID>, ID : AggregateId> { interface EventHandler<E : Event<ID>, ID : AggregateId> {
fun registerProjectionBuilder(builder: (E) -> Unit) fun registerProjectionBuilder(builder: (E) -> Unit)
fun handle( fun handle(
aggregateId: ID, aggregateId: ID,
buildEvent: (version: Int) -> E, buildEvent: (version: Int) -> E,
): E ): E
} }

View File

@@ -5,5 +5,5 @@ import eventDemo.app.event.event.GameEvent
import eventDemo.libs.event.EventBus import eventDemo.libs.event.EventBus
class GameEventBus( class GameEventBus(
bus: EventBus<GameEvent, GameId>, bus: EventBus<GameEvent, GameId>,
) : EventBus<GameEvent, GameId> by bus ) : EventBus<GameEvent, GameId> by bus

View File

@@ -12,30 +12,30 @@ import kotlin.concurrent.withLock
* A stream to publish and read the played card event. * A stream to publish and read the played card event.
*/ */
class GameEventHandler( class GameEventHandler(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val eventStore: GameEventStore, private val eventStore: GameEventStore,
private val versionBuilder: VersionBuilder, private val versionBuilder: VersionBuilder,
) : EventHandler<GameEvent, GameId> { ) : EventHandler<GameEvent, GameId> {
private val projectionsBuilders: ConcurrentLinkedQueue<(GameEvent) -> Unit> = ConcurrentLinkedQueue() private val projectionsBuilders: ConcurrentLinkedQueue<(GameEvent) -> Unit> = ConcurrentLinkedQueue()
private val locks: ConcurrentHashMap<GameId, ReentrantLock> = ConcurrentHashMap() private val locks: ConcurrentHashMap<GameId, ReentrantLock> = ConcurrentHashMap()
override fun registerProjectionBuilder(builder: GameProjectionBuilder) { override fun registerProjectionBuilder(builder: GameProjectionBuilder) {
projectionsBuilders.add(builder) projectionsBuilders.add(builder)
} }
override fun handle( override fun handle(
aggregateId: GameId, aggregateId: GameId,
buildEvent: (version: Int) -> GameEvent, buildEvent: (version: Int) -> GameEvent,
): GameEvent = ): GameEvent =
locks locks
.computeIfAbsent(aggregateId) { ReentrantLock() } .computeIfAbsent(aggregateId) { ReentrantLock() }
.withLock { .withLock {
buildEvent(versionBuilder.buildNextVersion(aggregateId)) buildEvent(versionBuilder.buildNextVersion(aggregateId))
.also { eventStore.publish(it) } .also { eventStore.publish(it) }
}.also { event -> }.also { event ->
projectionsBuilders.forEach { it(event) } projectionsBuilders.forEach { it(event) }
eventBus.publish(event) eventBus.publish(event)
} }
} }
typealias GameProjectionBuilder = (GameEvent) -> Unit typealias GameProjectionBuilder = (GameEvent) -> Unit

View File

@@ -8,5 +8,5 @@ import eventDemo.libs.event.EventStore
* A stream to publish and read the played card event. * A stream to publish and read the played card event.
*/ */
class GameEventStore( class GameEventStore(
private val eventStore: EventStore<GameEvent, GameId>, private val eventStore: EventStore<GameEvent, GameId>,
) : EventStore<GameEvent, GameId> by eventStore ) : EventStore<GameEvent, GameId> by eventStore

View File

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

View File

@@ -11,12 +11,12 @@ import java.util.UUID
* An [GameEvent] to represent a played card. * An [GameEvent] to represent a played card.
*/ */
data class CardIsPlayedEvent( data class CardIsPlayedEvent(
override val aggregateId: GameId, override val aggregateId: GameId,
val card: Card, val card: Card,
override val player: Player, override val player: Player,
override val version: Int, override val version: Int,
) : GameEvent, ) : GameEvent,
PlayerActionEvent { PlayerActionEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -10,7 +10,7 @@ import java.util.UUID
*/ */
@Serializable @Serializable
sealed interface GameEvent : Event<GameId> { sealed interface GameEvent : Event<GameId> {
override val eventId: UUID override val eventId: UUID
override val aggregateId: GameId override val aggregateId: GameId
override val version: Int override val version: Int
} }

View File

@@ -12,37 +12,37 @@ import java.util.UUID
* 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 aggregateId: GameId, override val aggregateId: GameId,
val firstPlayer: Player, val firstPlayer: Player,
val deck: Deck, val deck: Deck,
override val version: Int, override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
companion object { companion object {
fun new( fun new(
id: GameId, id: GameId,
players: Set<Player>, players: Set<Player>,
shuffleIsDisabled: Boolean = isDisabled, shuffleIsDisabled: Boolean = isDisabled,
version: Int, version: Int,
): GameStartedEvent = ): GameStartedEvent =
GameStartedEvent( GameStartedEvent(
aggregateId = id, aggregateId = id,
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(), firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck = deck =
Deck Deck
.newWithoutPlayers() .newWithoutPlayers()
.let { if (shuffleIsDisabled) it else it.shuffle() } .let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players) .initHands(players)
.placeFirstCardOnDiscard(), .placeFirstCardOnDiscard(),
version = version, version = version,
) )
} }
} }
private var isDisabled = false private var isDisabled = false
internal fun disableShuffleDeck() { internal fun disableShuffleDeck() {
isDisabled = true isDisabled = true
} }

View File

@@ -10,10 +10,10 @@ import java.util.UUID
* 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 aggregateId: GameId, override val aggregateId: GameId,
val player: Player, val player: Player,
override val version: Int, override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -3,5 +3,5 @@ package eventDemo.app.event.event
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
sealed interface PlayerActionEvent : GameEvent { sealed interface PlayerActionEvent : GameEvent {
val player: Player val player: Player
} }

View File

@@ -11,12 +11,12 @@ import java.util.UUID
* 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 aggregateId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
val color: Card.Color, val color: Card.Color,
override val version: Int, override val version: Int,
) : GameEvent, ) : GameEvent,
PlayerActionEvent { PlayerActionEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -11,12 +11,12 @@ import java.util.UUID
* 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 aggregateId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
val takenCard: Card, val takenCard: Card,
override val version: Int, override val version: Int,
) : GameEvent, ) : GameEvent,
PlayerActionEvent { PlayerActionEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -10,10 +10,10 @@ import java.util.UUID
* 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 aggregateId: GameId, override val aggregateId: GameId,
val player: Player, val player: Player,
override val version: Int, override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -10,10 +10,10 @@ import java.util.UUID
* This [GameEvent] is sent when a player is ready. * This [GameEvent] is sent when a player is ready.
*/ */
data class PlayerWinEvent( data class PlayerWinEvent(
override val aggregateId: GameId, override val aggregateId: GameId,
val player: Player, val player: Player,
override val version: Int, override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now() override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -8,173 +8,174 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GameState( data class GameState(
override val aggregateId: GameId, override val aggregateId: GameId,
override val lastEventVersion: Int = 0, override val lastEventVersion: Int = 0,
val players: Set<Player> = emptySet(), val players: Set<Player> = emptySet(),
val currentPlayerTurn: Player? = null, val currentPlayerTurn: Player? = null,
val cardOnCurrentStack: LastCard? = null, val cardOnCurrentStack: LastCard? = null,
val colorOnCurrentStack: Card.Color? = null, val colorOnCurrentStack: Card.Color? = null,
val direction: Direction = Direction.CLOCKWISE, val direction: Direction = Direction.CLOCKWISE,
val readyPlayers: Set<Player> = emptySet(), val readyPlayers: Set<Player> = emptySet(),
val deck: Deck = Deck(players), val deck: Deck = Deck(players),
val isStarted: Boolean = false, val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(), val playerWins: Set<Player> = emptySet(),
) : Projection<GameId> { ) : Projection<GameId> {
@Serializable @Serializable
data class LastCard( data class LastCard(
val card: Card, val card: Card,
val player: Player, val player: Player,
) )
enum class Direction { enum class Direction {
CLOCKWISE, CLOCKWISE,
COUNTER_CLOCKWISE, COUNTER_CLOCKWISE,
; ;
fun revert(): Direction = fun revert(): Direction =
if (this === CLOCKWISE) { if (this === CLOCKWISE) {
COUNTER_CLOCKWISE COUNTER_CLOCKWISE
} else { } else {
CLOCKWISE CLOCKWISE
} }
}
val isReady: Boolean get() {
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
}
private val currentPlayerIndex: Int? get() {
val i = players.indexOf(currentPlayerTurn)
return if (i == -1) {
null
} else {
i
}
}
private fun nextPlayerIndex(direction: Direction): Int {
if (players.isEmpty()) return 0
return if (direction == Direction.CLOCKWISE) {
sidePlayerIndexClockwise
} else {
sidePlayerIndexCounterClockwise
}
}
fun nextPlayer(direction: Direction): Player =
players.elementAt(nextPlayerIndex(direction))
private val sidePlayerIndexClockwise: Int by lazy {
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) + 1) % players.size
}
}
private val sidePlayerIndexCounterClockwise: Int by lazy {
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) - 1) % players.size
}
}
val nextPlayerTurn: Player? by lazy {
if (players.isEmpty()) {
null
} else {
nextPlayer(direction)
}
}
private val Player.currentIndex: Int get() = players.indexOf(this)
fun Player.playerDiffIndex(nextPlayer: Player): Int =
if (direction == Direction.CLOCKWISE) {
nextPlayer.currentIndex + this.currentIndex
} else {
nextPlayer.currentIndex - this.currentIndex
}.let { it % players.size }
val Player.cardOnBoardIsForYou: Boolean get() {
if (cardOnCurrentStack == null) error("No card")
return this.playerDiffIndex(cardOnCurrentStack.player) == 1
}
fun playableCards(player: Player): List<Card> =
deck
.playersHands
.getHand(player)
?.filter { canBePlayThisCard(player, it) }
?: emptyList()
fun playerHasNoCardLeft(): List<Player> =
deck.playerHasNoCardLeft().map { playerId ->
players.find { it.id == playerId } ?: error("inconsistency detected between players")
} }
val isReady: Boolean get() { fun canBePlayThisCard(
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) } player: Player,
} card: Card,
): Boolean {
val cardOnBoard = cardOnCurrentStack?.card ?: return false
return when (cardOnBoard) {
is Card.NumericCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
private val currentPlayerIndex: Int? get() { is Card.ReverseCard -> {
val i = players.indexOf(currentPlayerTurn) when (card) {
return if (i == -1) { is Card.ReverseCard -> true
null is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.PassCard -> {
if (player.cardOnBoardIsForYou) {
false
} else { } else {
i when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
} }
} }
private fun nextPlayerIndex(direction: Direction): Int { is Card.ChangeColorCard -> {
if (players.isEmpty()) return 0 when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
return if (direction == Direction.CLOCKWISE) { is Card.Plus2Card -> {
sidePlayerIndexClockwise if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
true
} else { } else {
sidePlayerIndexCounterClockwise when (card) {
is Card.AllColorCard -> true
is Card.Plus2Card -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
} }
} }
fun nextPlayer(direction: Direction): Player = players.elementAt(nextPlayerIndex(direction)) is Card.Plus4Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
private val sidePlayerIndexClockwise: Int by lazy { true
if (players.isEmpty()) {
0
} else { } else {
((currentPlayerIndex ?: 0) + 1) % players.size when (card) {
} is Card.AllColorCard -> true
} is Card.ColorCard -> card.color == colorOnCurrentStack
private val sidePlayerIndexCounterClockwise: Int by lazy { }
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) - 1) % players.size
}
}
val nextPlayerTurn: Player? by lazy {
if (players.isEmpty()) {
null
} else {
nextPlayer(direction)
}
}
private val Player.currentIndex: Int get() = players.indexOf(this)
fun Player.playerDiffIndex(nextPlayer: Player): Int =
if (direction == Direction.CLOCKWISE) {
nextPlayer.currentIndex + this.currentIndex
} else {
nextPlayer.currentIndex - this.currentIndex
}.let { it % players.size }
val Player.cardOnBoardIsForYou: Boolean get() {
if (cardOnCurrentStack == null) error("No card")
return this.playerDiffIndex(cardOnCurrentStack.player) == 1
}
fun playableCards(player: Player): List<Card> =
deck
.playersHands
.getHand(player)
?.filter { canBePlayThisCard(player, it) }
?: emptyList()
fun playerHasNoCardLeft(): List<Player> =
deck.playerHasNoCardLeft().map { playerId ->
players.find { it.id == playerId } ?: error("inconsistency detected between players")
}
fun canBePlayThisCard(
player: Player,
card: Card,
): Boolean {
val cardOnBoard = cardOnCurrentStack?.card ?: return false
return when (cardOnBoard) {
is Card.NumericCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.ReverseCard -> {
when (card) {
is Card.ReverseCard -> true
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.PassCard -> {
if (player.cardOnBoardIsForYou) {
false
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
}
is Card.ChangeColorCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
is Card.Plus2Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.Plus2Card -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
}
is Card.Plus4Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
}
} }
}
} }
}
} }

View File

@@ -13,103 +13,103 @@ import eventDemo.app.event.event.PlayerWinEvent
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
fun GameState.apply(event: GameEvent): GameState = fun GameState.apply(event: GameEvent): GameState =
this.let { state -> this.let { state ->
val logger = KotlinLogging.logger { } val logger = KotlinLogging.logger { }
if (event is PlayerActionEvent) { if (event is PlayerActionEvent) {
if (state.currentPlayerTurn != event.player) { if (state.currentPlayerTurn != event.player) {
logger.atError { logger.atError {
message = "Inconsistent player turn. CurrentPlayerTurn: $state.currentPlayerTurn | Player: ${event.player}" message = "Inconsistent player turn. CurrentPlayerTurn: $state.currentPlayerTurn | Player: ${event.player}"
payload = payload =
mapOf( mapOf(
"CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"), "CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
"Player" to event.player, "Player" to event.player,
) )
} }
} }
}
when (event) {
is CardIsPlayedEvent -> {
val nextDirectionAfterPlay =
when (event.card) {
is Card.ReverseCard -> state.direction.revert()
else -> state.direction
}
val color =
when (event.card) {
is Card.ColorCard -> event.card.color
is Card.AllColorCard -> null
}
val currentPlayerAfterThePlay =
if (event.card is Card.AllColorCard) {
state.currentPlayerTurn
} else {
state.nextPlayer(nextDirectionAfterPlay)
}
state.copy(
currentPlayerTurn = currentPlayerAfterThePlay,
direction = nextDirectionAfterPlay,
colorOnCurrentStack = color,
cardOnCurrentStack = GameState.LastCard(event.card, event.player),
deck = state.deck.putOneCardFromHand(event.player, event.card),
)
}
is NewPlayerEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
} }
when (event) { state.copy(
is CardIsPlayedEvent -> { players = state.players + event.player,
val nextDirectionAfterPlay =
when (event.card) {
is Card.ReverseCard -> state.direction.revert()
else -> state.direction
}
val color =
when (event.card) {
is Card.ColorCard -> event.card.color
is Card.AllColorCard -> null
}
val currentPlayerAfterThePlay =
if (event.card is Card.AllColorCard) {
state.currentPlayerTurn
} else {
state.nextPlayer(nextDirectionAfterPlay)
}
state.copy(
currentPlayerTurn = currentPlayerAfterThePlay,
direction = nextDirectionAfterPlay,
colorOnCurrentStack = color,
cardOnCurrentStack = GameState.LastCard(event.card, event.player),
deck = state.deck.putOneCardFromHand(event.player, event.card),
)
}
is NewPlayerEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
players = state.players + event.player,
)
}
is PlayerReadyEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
readyPlayers = state.readyPlayers + event.player,
)
}
is PlayerHavePassEvent -> {
if (event.takenCard != state.deck.stack.first()) {
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
}
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
deck = state.deck.takeOneCardFromStackTo(event.player),
)
}
is PlayerChoseColorEvent -> {
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
colorOnCurrentStack = event.color,
)
}
is GameStartedEvent -> {
state.copy(
colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
cardOnCurrentStack = GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
currentPlayerTurn = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
is PlayerWinEvent -> {
state.copy(
playerWins = state.playerWins + event.player,
)
}
}.copy(
lastEventVersion = event.version,
) )
} }
is PlayerReadyEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
readyPlayers = state.readyPlayers + event.player,
)
}
is PlayerHavePassEvent -> {
if (event.takenCard != state.deck.stack.first()) {
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
}
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
deck = state.deck.takeOneCardFromStackTo(event.player),
)
}
is PlayerChoseColorEvent -> {
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
colorOnCurrentStack = event.color,
)
}
is GameStartedEvent -> {
state.copy(
colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
cardOnCurrentStack = GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
currentPlayerTurn = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
is PlayerWinEvent -> {
state.copy(
playerWins = state.playerWins + event.player,
)
}
}.copy(
lastEventVersion = event.version,
)
}

View File

@@ -6,36 +6,38 @@ import eventDemo.app.event.GameEventStore
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
class GameStateRepository( class GameStateRepository(
eventStore: GameEventStore, eventStore: GameEventStore,
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
snapshotConfig: SnapshotConfig = SnapshotConfig(), snapshotConfig: SnapshotConfig = SnapshotConfig(),
) { ) {
private val projectionsSnapshot = private val projectionsSnapshot =
ProjectionSnapshotRepositoryInMemory( ProjectionSnapshotRepositoryInMemory(
eventStore = eventStore, eventStore = eventStore,
snapshotCacheConfig = snapshotConfig, snapshotCacheConfig = snapshotConfig,
applyToProjection = GameState::apply, applyToProjection = GameState::apply,
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) }, initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
) )
init { init {
eventHandler.registerProjectionBuilder { event -> eventHandler.registerProjectionBuilder { event ->
projectionsSnapshot.applyAndPutToCache(event) projectionsSnapshot.applyAndPutToCache(event)
}
} }
}
/** /**
* Get the last version of the [GameState] from the all eventStream. * Get the last version of the [GameState] from the all eventStream.
* *
* It fetches it from the local cache if possible, otherwise it builds it. * It fetches it from the local cache if possible, otherwise it builds it.
*/ */
fun getLast(gameId: GameId): GameState = projectionsSnapshot.getLast(gameId) fun getLast(gameId: GameId): GameState =
projectionsSnapshot.getLast(gameId)
/** /**
* Get the [GameState] to the specific [event][GameEvent]. * Get the [GameState] to the specific [event][GameEvent].
* It does not contain the [events][GameEvent] it after this one. * It does not contain the [events][GameEvent] it after this one.
* *
* It fetches it from the local cache if possible, otherwise it builds it. * It fetches it from the local cache if possible, otherwise it builds it.
*/ */
fun getUntil(event: GameEvent): GameState = projectionsSnapshot.getUntil(event) fun getUntil(event: GameEvent): GameState =
projectionsSnapshot.getUntil(event)
} }

View File

@@ -3,6 +3,6 @@ package eventDemo.app.event.projection
import eventDemo.libs.event.AggregateId import eventDemo.libs.event.AggregateId
interface Projection<ID : AggregateId> { interface Projection<ID : AggregateId> {
val aggregateId: ID val aggregateId: ID
val lastEventVersion: Int val lastEventVersion: Int
} }

View File

@@ -13,182 +13,183 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
data class SnapshotConfig( data class SnapshotConfig(
val maxSnapshotCacheSize: Int = 20, val maxSnapshotCacheSize: Int = 20,
val maxSnapshotCacheTtl: Duration = 10.minutes, val maxSnapshotCacheTtl: Duration = 10.minutes,
/** /**
* Only create [snapshots][Projection] every [X][modulo] [events][Event] * Only create [snapshots][Projection] every [X][modulo] [events][Event]
*/ */
val modulo: Int = 10, val modulo: Int = 10,
) )
class ProjectionSnapshotRepositoryInMemory<E : Event<ID>, P : Projection<ID>, ID : AggregateId>( class ProjectionSnapshotRepositoryInMemory<E : Event<ID>, P : Projection<ID>, ID : AggregateId>(
private val eventStore: EventStore<E, ID>, private val eventStore: EventStore<E, ID>,
private val initialStateBuilder: (ID) -> P, private val initialStateBuilder: (ID) -> P,
private val snapshotCacheConfig: SnapshotConfig = SnapshotConfig(), private val snapshotCacheConfig: SnapshotConfig = SnapshotConfig(),
private val applyToProjection: P.(event: E) -> P, private val applyToProjection: P.(event: E) -> P,
) { ) {
private val projectionsSnapshot: ConcurrentHashMap<ID, ConcurrentLinkedQueue<Pair<P, Instant>>> = ConcurrentHashMap() private val projectionsSnapshot: ConcurrentHashMap<ID, ConcurrentLinkedQueue<Pair<P, Instant>>> = ConcurrentHashMap()
/** /**
* Create a snapshot for the event * Create a snapshot for the event
* *
* 1. get the last snapshot with a version lower than that of the event * 1. get the last snapshot with a version lower than that of the event
* 2. get the events with a greater version of the snapshot * 2. get the events with a greater version of the snapshot
* 3. apply the event to the snapshot * 3. apply the event to the snapshot
* 4. apply the new event to the projection * 4. apply the new event to the projection
* 5. save it * 5. save it
* 6. remove old one * 6. remove old one
*/ */
fun applyAndPutToCache(event: E) { fun applyAndPutToCache(event: E) {
if ((event.version % snapshotCacheConfig.modulo) == 0) { if ((event.version % snapshotCacheConfig.modulo) == 0) {
getUntil(event) getUntil(event)
.also { .also {
save(it) save(it)
removeOldSnapshot(it.aggregateId) removeOldSnapshot(it.aggregateId)
}
} }
} }
}
/** /**
* Build the last version of the [Projection] from the cache. * Build the last version of the [Projection] from the cache.
* *
* 1. get the last snapshot * 1. get the last snapshot
* 2. get the missing event to the snapshot * 2. get the missing event to the snapshot
* 3. apply the missing events to the snapshot * 3. apply the missing events to the snapshot
*/ */
fun getLast(aggregateId: ID): P { fun getLast(aggregateId: ID): P {
val lastSnapshot = getLastSnapshot(aggregateId)?.first val lastSnapshot = getLastSnapshot(aggregateId)?.first
val missingEventOfSnapshot = getEventAfterTheSnapshot(aggregateId, lastSnapshot) val missingEventOfSnapshot = getEventAfterTheSnapshot(aggregateId, lastSnapshot)
return lastSnapshot.applyEvents(aggregateId, missingEventOfSnapshot) return lastSnapshot.applyEvents(aggregateId, missingEventOfSnapshot)
}
/**
* Build the [Projection] to the specific [event][Event].
*
* It does not contain the [events][Event] it after this one.
*
* 1. get the last snapshot before the event
* 2. get the events with a greater version of the snapshot but lower of passed event
* 3. apply the events to the snapshot
*/
fun getUntil(event: E): P {
val lastSnapshot = getLastSnapshotBeforeOrEqualEvent(event)?.first
if (lastSnapshot?.lastEventVersion == event.version) {
return lastSnapshot
} }
/** val missingEventOfSnapshot =
* Build the [Projection] to the specific [event][Event]. eventStore
* .getStream(event.aggregateId)
* It does not contain the [events][Event] it after this one. .readVersionBetween((lastSnapshot?.lastEventVersion ?: 1)..event.version)
*
* 1. get the last snapshot before the event
* 2. get the events with a greater version of the snapshot but lower of passed event
* 3. apply the events to the snapshot
*/
fun getUntil(event: E): P {
val lastSnapshot = getLastSnapshotBeforeOrEqualEvent(event)?.first
if (lastSnapshot?.lastEventVersion == event.version) {
return lastSnapshot
}
val missingEventOfSnapshot = return if (lastSnapshot?.lastEventVersion == event.version) {
eventStore lastSnapshot
.getStream(event.aggregateId) } else {
.readVersionBetween((lastSnapshot?.lastEventVersion ?: 1)..event.version) lastSnapshot.applyEvents(event.aggregateId, missingEventOfSnapshot)
return if (lastSnapshot?.lastEventVersion == event.version) {
lastSnapshot
} else {
lastSnapshot.applyEvents(event.aggregateId, missingEventOfSnapshot)
}
} }
}
/** /**
* Remove the oldest snapshot. * Remove the oldest snapshot.
* *
* The rules are pass in the controller. * The rules are pass in the controller.
*/ */
private fun removeOldSnapshot(aggregateId: ID) { private fun removeOldSnapshot(aggregateId: ID) {
projectionsSnapshot[aggregateId]?.let { queue -> projectionsSnapshot[aggregateId]?.let { queue ->
// never remove the last one // never remove the last one
val theLastOne = getLastSnapshot(aggregateId) val theLastOne = getLastSnapshot(aggregateId)
removeByDate(queue, theLastOne) removeByDate(queue, theLastOne)
removeBySize(queue, theLastOne) removeBySize(queue, theLastOne)
}
} }
}
private fun removeBySize( private fun removeBySize(
queue: ConcurrentLinkedQueue<Pair<P, Instant>>, queue: ConcurrentLinkedQueue<Pair<P, Instant>>,
theLastOne: Pair<P, Instant>?, theLastOne: Pair<P, Instant>?,
) { ) {
// Remove if size exceeds the limit // Remove if size exceeds the limit
val size = queue.size val size = queue.size
if (size > snapshotCacheConfig.maxSnapshotCacheSize) { if (size > snapshotCacheConfig.maxSnapshotCacheSize) {
val numberToRemove = size - snapshotCacheConfig.maxSnapshotCacheSize val numberToRemove = size - snapshotCacheConfig.maxSnapshotCacheSize
if (numberToRemove > 0) { if (numberToRemove > 0) {
queue queue
.sortedBy { it.first.lastEventVersion } .sortedBy { it.first.lastEventVersion }
.take(numberToRemove) .take(numberToRemove)
.let { it - theLastOne } .let { it - theLastOne }
.forEach { queue.remove(it) } .forEach { queue.remove(it) }
} }
}
} }
}
private fun removeByDate( private fun removeByDate(
queue: ConcurrentLinkedQueue<Pair<P, Instant>>, queue: ConcurrentLinkedQueue<Pair<P, Instant>>,
theLastOne: Pair<P, Instant>?, theLastOne: Pair<P, Instant>?,
) { ) {
// remove the oldest by time // remove the oldest by time
val now = Clock.System.now() val now = Clock.System.now()
val deadLine = now - snapshotCacheConfig.maxSnapshotCacheTtl val deadLine = now - snapshotCacheConfig.maxSnapshotCacheTtl
val toRemove = queue.filter { deadLine > it.second } val toRemove = queue.filter { deadLine > it.second }
(toRemove - theLastOne).forEach { queue.remove(it) } (toRemove - theLastOne).forEach { queue.remove(it) }
} }
/** /**
* Save the snapshot. * Save the snapshot.
*/ */
private fun save(projection: P) { private fun save(projection: P) {
projectionsSnapshot projectionsSnapshot
.computeIfAbsent(projection.aggregateId) { ConcurrentLinkedQueue() } .computeIfAbsent(projection.aggregateId) { ConcurrentLinkedQueue() }
.add(Pair(projection, Clock.System.now())) .add(Pair(projection, Clock.System.now()))
} }
/** /**
* Get the last snapshot when the version is lower of then event version * Get the last snapshot when the version is lower of then event version
*/ */
private fun getLastSnapshotBeforeOrEqualEvent(event: E) = private fun getLastSnapshotBeforeOrEqualEvent(event: E) =
projectionsSnapshot[event.aggregateId] projectionsSnapshot[event.aggregateId]
?.sortedByDescending { it.first.lastEventVersion } ?.sortedByDescending { it.first.lastEventVersion }
?.find { it.first.lastEventVersion <= event.version } ?.find { it.first.lastEventVersion <= event.version }
/** /**
* Get the last snapshot (with the higher version). * Get the last snapshot (with the higher version).
*/ */
private fun getLastSnapshot(aggregateId: ID) = private fun getLastSnapshot(aggregateId: ID) =
projectionsSnapshot[aggregateId] projectionsSnapshot[aggregateId]
?.maxByOrNull { it.first.lastEventVersion } ?.maxByOrNull { it.first.lastEventVersion }
/** /**
* Get the events from the [event stream][EventStream] when the version is higher of the snapshot. * Get the events from the [event stream][EventStream] when the version is higher of the snapshot.
* *
* If the snapshot is null, it takes all events from the event [event stream][EventStream] * If the snapshot is null, it takes all events from the event [event stream][EventStream]
*/ */
private fun getEventAfterTheSnapshot( private fun getEventAfterTheSnapshot(
aggregateId: ID, aggregateId: ID,
snapshot: P?, snapshot: P?,
) = eventStore ) =
.getStream(aggregateId) eventStore
.readGreaterOfVersion(snapshot?.lastEventVersion ?: 0) .getStream(aggregateId)
.readGreaterOfVersion(snapshot?.lastEventVersion ?: 0)
/**
* Apply events to the projection. /**
*/ * Apply events to the projection.
private fun P?.applyEvents( */
aggregateId: ID, private fun P?.applyEvents(
eventsToApply: Set<E>, aggregateId: ID,
): P = eventsToApply: Set<E>,
eventsToApply ): P =
.fold(this ?: initialStateBuilder(aggregateId), applyToProjectionSecure) eventsToApply
.fold(this ?: initialStateBuilder(aggregateId), applyToProjectionSecure)
/**
* Wrap the [applyToProjection] lambda to avoid duplicate apply of the same event. /**
*/ * Wrap the [applyToProjection] lambda to avoid duplicate apply of the same event.
private val applyToProjectionSecure: P.(event: E) -> P = { event -> */
if (event.version == lastEventVersion + 1) { private val applyToProjectionSecure: P.(event: E) -> P = { event ->
applyToProjection(event) if (event.version == lastEventVersion + 1) {
} else if (event.version <= lastEventVersion) { applyToProjection(event)
KotlinLogging.logger { }.warn { "Event is already is the Projection, skip apply." } } else if (event.version <= lastEventVersion) {
this KotlinLogging.logger { }.warn { "Event is already is the Projection, skip apply." }
} else { this
error("The version of the event must follow directly after the version of the projection.") } else {
} error("The version of the event must follow directly after the version of the projection.")
} }
}
} }

View File

@@ -28,118 +28,118 @@ import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
class PlayerNotificationEventListener( class PlayerNotificationEventListener(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val gameStateRepository: GameStateRepository, private val gameStateRepository: GameStateRepository,
) { ) {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
fun startListening( fun startListening(
outgoingNotificationChannel: SendChannel<Notification>, outgoingNotificationChannel: SendChannel<Notification>,
currentPlayer: Player, currentPlayer: Player,
) { ) {
eventBus.subscribe { event: GameEvent -> eventBus.subscribe { event: GameEvent ->
val currentState = gameStateRepository.getUntil(event) val currentState = gameStateRepository.getUntil(event)
fun Notification.send() { fun Notification.send() {
if (currentState.players.contains(currentPlayer)) { if (currentState.players.contains(currentPlayer)) {
// Only notify players who have already joined the game. // Only notify players who have already joined the game.
outgoingNotificationChannel.trySendBlocking(this) outgoingNotificationChannel.trySendBlocking(this)
logger.atInfo { logger.atInfo {
message = "Notification for player ${currentPlayer.name} was SEND: ${this@send}" message = "Notification for player ${currentPlayer.name} was SEND: ${this@send}"
payload = mapOf("notification" to this@send, "event" to event) payload = mapOf("notification" to this@send, "event" to event)
} }
} else { } else {
// Rare use case, when a connexion is created with the channel, // Rare use case, when a connexion is created with the channel,
// but the player was not already join in the game // but the player was not already join in the game
logger.atWarn { logger.atWarn {
message = "Notification for player ${currentPlayer.name} was SKIP, No player on the game: ${this@send}" message = "Notification for player ${currentPlayer.name} was SKIP, No player on the game: ${this@send}"
payload = mapOf("notification" to this@send, "event" to event) payload = mapOf("notification" to this@send, "event" to event)
} }
}
}
fun sendNextTurnNotif() =
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
when (event) {
is NewPlayerEvent -> {
if (currentPlayer != event.player) {
PlayerAsJoinTheGameNotification(
player = event.player,
).send()
} else {
WelcomeToTheGameNotification(
players = currentState.players,
).send()
}
}
is CardIsPlayedEvent -> {
if (currentPlayer != event.player) {
PlayerAsPlayACardNotification(
player = event.player,
card = event.card,
).send()
}
if (event.card !is Card.AllColorCard) {
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
}
}
is GameStartedEvent -> {
TheGameWasStartedNotification(
hand =
event.deck.playersHands.getHand(currentPlayer)
?: error("You are not in the game"),
).send()
sendNextTurnNotif()
}
is PlayerChoseColorEvent -> {
if (currentPlayer != event.player) {
PlayerWasChoseTheCardColorNotification(
player = event.player,
color = event.color,
).send()
}
sendNextTurnNotif()
}
is PlayerHavePassEvent -> {
if (currentPlayer == event.player) {
YourNewCardNotification(
card = event.takenCard,
).send()
} else {
PlayerHavePassNotification(
player = event.player,
).send()
}
sendNextTurnNotif()
}
is PlayerReadyEvent -> {
if (currentPlayer != event.player) {
PlayerWasReadyNotification(
player = event.player,
).send()
}
}
is PlayerWinEvent -> {
PlayerWinNotification(
player = event.player,
).send()
}
}
} }
}
fun sendNextTurnNotif() =
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
when (event) {
is NewPlayerEvent -> {
if (currentPlayer != event.player) {
PlayerAsJoinTheGameNotification(
player = event.player,
).send()
} else {
WelcomeToTheGameNotification(
players = currentState.players,
).send()
}
}
is CardIsPlayedEvent -> {
if (currentPlayer != event.player) {
PlayerAsPlayACardNotification(
player = event.player,
card = event.card,
).send()
}
if (event.card !is Card.AllColorCard) {
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
}
}
is GameStartedEvent -> {
TheGameWasStartedNotification(
hand =
event.deck.playersHands.getHand(currentPlayer)
?: error("You are not in the game"),
).send()
sendNextTurnNotif()
}
is PlayerChoseColorEvent -> {
if (currentPlayer != event.player) {
PlayerWasChoseTheCardColorNotification(
player = event.player,
color = event.color,
).send()
}
sendNextTurnNotif()
}
is PlayerHavePassEvent -> {
if (currentPlayer == event.player) {
YourNewCardNotification(
card = event.takenCard,
).send()
} else {
PlayerHavePassNotification(
player = event.player,
).send()
}
sendNextTurnNotif()
}
is PlayerReadyEvent -> {
if (currentPlayer != event.player) {
PlayerWasReadyNotification(
player = event.player,
).send()
}
}
is PlayerWinEvent -> {
PlayerWinNotification(
player = event.player,
).send()
}
}
} }
}
} }

View File

@@ -11,76 +11,76 @@ import eventDemo.app.event.projection.GameStateRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
class ReactionEventListener( class ReactionEventListener(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val eventHandler: GameEventHandler, private val eventHandler: GameEventHandler,
private val gameStateRepository: GameStateRepository, private val gameStateRepository: GameStateRepository,
private val priority: Int = DEFAULT_PRIORITY, private val priority: Int = DEFAULT_PRIORITY,
) { ) {
companion object Config { companion object Config {
const val DEFAULT_PRIORITY = -1000 const val DEFAULT_PRIORITY = -1000
}
private val logger = KotlinLogging.logger { }
fun init() {
eventBus.subscribe(priority) { event: GameEvent ->
val state = gameStateRepository.getUntil(event)
sendStartGameEvent(state, event)
sendWinnerEvent(state, event)
} }
}
private val logger = KotlinLogging.logger { } private suspend fun sendStartGameEvent(
state: GameState,
fun init() { event: GameEvent,
eventBus.subscribe(priority) { event: GameEvent -> ) {
val state = gameStateRepository.getUntil(event) if (state.isReady && !state.isStarted) {
sendStartGameEvent(state, event) val reactionEvent =
sendWinnerEvent(state, event) eventHandler.handle(state.aggregateId) {
GameStartedEvent.new(
id = state.aggregateId,
players = state.players,
version = it,
)
} }
logger.atInfo {
message = "Reaction event was Send $reactionEvent on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
}
} else {
if (event is PlayerReadyEvent) {
logger.info { "All players was not ready ${state.readyPlayers}" }
}
} }
}
private suspend fun sendStartGameEvent( private fun sendWinnerEvent(
state: GameState, state: GameState,
event: GameEvent, event: GameEvent,
) { ) {
if (state.isReady && !state.isStarted) { val winner = state.playerHasNoCardLeft().firstOrNull()
val reactionEvent = if (winner != null) {
eventHandler.handle(state.aggregateId) { val reactionEvent =
GameStartedEvent.new( eventHandler.handle(state.aggregateId) {
id = state.aggregateId, PlayerWinEvent(
players = state.players, aggregateId = state.aggregateId,
version = it, player = winner,
) version = it,
} )
logger.atInfo {
message = "Reaction event was Send $reactionEvent on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
}
} else {
if (event is PlayerReadyEvent) {
logger.info { "All players was not ready ${state.readyPlayers}" }
}
} }
}
private fun sendWinnerEvent( logger.atInfo {
state: GameState, message = "Reaction event was Send $reactionEvent on reaction of: $event"
event: GameEvent, payload =
) { mapOf(
val winner = state.playerHasNoCardLeft().firstOrNull() "event" to event,
if (winner != null) { "reactionEvent" to reactionEvent,
val reactionEvent = )
eventHandler.handle(state.aggregateId) { }
PlayerWinEvent(
aggregateId = state.aggregateId,
player = winner,
version = it,
)
}
logger.atInfo {
message = "Reaction event was Send $reactionEvent on reaction of: $event"
payload =
mapOf(
"event" to event,
"reactionEvent" to reactionEvent,
)
}
}
} }
}
} }

View File

@@ -6,7 +6,7 @@ import java.util.UUID
@Serializable @Serializable
data class ErrorNotification( data class ErrorNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val message: String, val message: String,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class ItsTheTurnOfNotification( data class ItsTheTurnOfNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
) : Notification ) : Notification

View File

@@ -6,6 +6,6 @@ import java.util.UUID
@Serializable @Serializable
sealed interface Notification { sealed interface Notification {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
val id: UUID val id: UUID
} }

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class PlayerAsJoinTheGameNotification( data class PlayerAsJoinTheGameNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
) : Notification ) : Notification

View File

@@ -8,8 +8,8 @@ import java.util.UUID
@Serializable @Serializable
data class PlayerAsPlayACardNotification( data class PlayerAsPlayACardNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
val card: Card, val card: Card,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class PlayerHavePassNotification( data class PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
) : Notification ) : Notification

View File

@@ -8,8 +8,8 @@ import java.util.UUID
@Serializable @Serializable
data class PlayerWasChoseTheCardColorNotification( data class PlayerWasChoseTheCardColorNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
val color: Card.Color, val color: Card.Color,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class PlayerWasReadyNotification( data class PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class PlayerWinNotification( data class PlayerWinNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val player: Player, val player: Player,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class TheGameWasStartedNotification( data class TheGameWasStartedNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val hand: List<Card>, val hand: List<Card>,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class WelcomeToTheGameNotification( data class WelcomeToTheGameNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val players: Set<Player>, val players: Set<Player>,
) : Notification ) : Notification

View File

@@ -7,7 +7,7 @@ import java.util.UUID
@Serializable @Serializable
data class YourNewCardNotification( data class YourNewCardNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val card: Card, val card: Card,
) : Notification ) : Notification

View File

@@ -15,41 +15,41 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
@Resource("/game/{id}") @Resource("/game/{id}")
class Game( class Game(
@Serializable(with = GameIdSerializer::class) @Serializable(with = GameIdSerializer::class)
val id: GameId, val id: GameId,
) { ) {
@Serializable @Serializable
@Resource("card/last") @Resource("card/last")
class Card( class Card(
val game: Game, val game: Game,
) )
@Serializable @Serializable
@Resource("state") @Resource("state")
class State( class State(
val game: Game, val game: Game,
) )
} }
/** /**
* API routes to read the game state. * API routes to read the game state.
*/ */
fun Route.readTheGameState(gameStateRepository: GameStateRepository) { fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
authenticate { authenticate {
// Read the last played card on the game. // Read the last played card on the game.
get<Game.Card> { body -> get<Game.Card> { body ->
gameStateRepository gameStateRepository
.getLast(body.game.id) .getLast(body.game.id)
.cardOnCurrentStack .cardOnCurrentStack
?.card ?.card
?.let { call.respond(it) } ?.let { call.respond(it) }
?: call.response.status(HttpStatusCode.BadRequest) ?: call.response.status(HttpStatusCode.BadRequest)
}
// Read the last played card on the game.
get<Game.State> { body ->
val state = gameStateRepository.getLast(body.game.id)
call.respond(state)
}
} }
// Read the last played card on the game.
get<Game.State> { body ->
val state = gameStateRepository.getLast(body.game.id)
call.respond(state)
}
}
} }

View File

@@ -4,17 +4,17 @@ import io.ktor.server.application.Application
import org.koin.ktor.ext.get import org.koin.ktor.ext.get
fun Application.configure() { fun Application.configure() {
configureKoin() configureKoin()
configureSecurity() configureSecurity()
configureSerialization() configureSerialization()
configureWebSockets() configureWebSockets()
declareWebSocketsGameRoute(get(), get()) declareWebSocketsGameRoute(get(), get())
configureHttpRouting() configureHttpRouting()
declareHttpGameRoute() declareHttpGameRoute()
configureGameListener() configureGameListener()
} }

View File

@@ -21,43 +21,43 @@ private val jwtIssuer = "PlayCardGame"
private val jwtSecret = "secret" private val jwtSecret = "secret"
fun Application.configureSecurity() { fun Application.configureSecurity() {
authentication { authentication {
jwt { jwt {
realm = jwtRealm realm = jwtRealm
verifier( verifier(
JWT JWT
.require(Algorithm.HMAC256(jwtSecret)) .require(Algorithm.HMAC256(jwtSecret))
.withIssuer(jwtIssuer) .withIssuer(jwtIssuer)
.build(), .build(),
) )
validate { credential -> validate { credential ->
if (credential.payload.getClaim("username").asString() != "") { if (credential.payload.getClaim("username").asString() != "") {
JWTPrincipal(credential.payload) JWTPrincipal(credential.payload)
} else { } else {
null null
}
}
challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
} }
}
challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
} }
}
routing { routing {
post("login/{username}") { post("login/{username}") {
val username = call.parameters["username"]!! val username = call.parameters["username"]!!
val player = Player(name = username) val player = Player(name = username)
call.respond(hashMapOf("token" to player.makeJwt())) call.respond(hashMapOf("token" to player.makeJwt()))
}
} }
}
} }
fun Player.makeJwt(): String = fun Player.makeJwt(): String =
JWT JWT
.create() .create()
.withIssuer(jwtIssuer) .withIssuer(jwtIssuer)
.withClaim("username", name) .withClaim("username", name)
.withPayload(Json.encodeToString(this)) .withPayload(Json.encodeToString(this))
.withExpiresAt(Date(System.currentTimeMillis() + 60000)) .withExpiresAt(Date(System.currentTimeMillis() + 60000))
.sign(Algorithm.HMAC256(jwtSecret)) .sign(Algorithm.HMAC256(jwtSecret))

View File

@@ -23,30 +23,30 @@ import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger import org.koin.logger.slf4jLogger
fun Application.configureKoin() { fun Application.configureKoin() {
install(Koin) { install(Koin) {
slf4jLogger() slf4jLogger()
modules(appKoinModule) modules(appKoinModule)
} }
} }
val appKoinModule = val appKoinModule =
module { module {
single { single {
GameEventBus(EventBusInMemory()) GameEventBus(EventBusInMemory())
}
single {
GameEventStore(EventStoreInMemory())
}
single {
GameStateRepository(get(), get(), snapshotConfig = SnapshotConfig())
}
single {
CommandStreamChannelBuilder<GameCommand>()
}
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
singleOf(::GameEventHandler)
singleOf(::GameCommandRunner)
singleOf(::GameCommandHandler)
singleOf(::PlayerNotificationEventListener)
} }
single {
GameEventStore(EventStoreInMemory())
}
single {
GameStateRepository(get(), get(), snapshotConfig = SnapshotConfig())
}
single {
CommandStreamChannelBuilder<GameCommand>()
}
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
singleOf(::GameEventHandler)
singleOf(::GameCommandRunner)
singleOf(::GameCommandHandler)
singleOf(::PlayerNotificationEventListener)
}

View File

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

View File

@@ -12,41 +12,41 @@ import io.ktor.server.resources.Resources
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
fun Application.configureHttpRouting() { fun Application.configureHttpRouting() {
install(CORS) { install(CORS) {
allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Post) allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Authorization)
allowHeader("MyCustomHeader") allowHeader("MyCustomHeader")
anyHost() // @TODO: Don't do this in production if possible. Try to limit it. anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
}
install(AutoHeadResponse)
install(Resources)
install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
} }
install(AutoHeadResponse) exception<Throwable> { call, cause ->
install(Resources) call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
}
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
} }
}
} }
class BadRequestException( class BadRequestException(
val httpError: HttpErrorBadRequest, val httpError: HttpErrorBadRequest,
) : Exception() ) : Exception()
class HttpErrorBadRequest( class HttpErrorBadRequest(
statusCode: HttpStatusCode, statusCode: HttpStatusCode,
val title: String = statusCode.description, val title: String = statusCode.description,
val invalidParams: List<InvalidParam>, val invalidParams: List<InvalidParam>,
) { ) {
val statusCode: Int = statusCode.value val statusCode: Int = statusCode.value
data class InvalidParam( data class InvalidParam(
val name: String, val name: String,
val reason: String, val reason: String,
) )
} }

View File

@@ -18,72 +18,76 @@ import kotlinx.serialization.modules.SerializersModule
import java.util.UUID import java.util.UUID
fun Application.configureSerialization() { fun Application.configureSerialization() {
install(ContentNegotiation) { install(ContentNegotiation) {
json( json(
defaultJsonSerializer(), defaultJsonSerializer(),
) )
} }
} }
fun defaultJsonSerializer(): Json = fun defaultJsonSerializer(): Json =
Json { Json {
serializersModule = serializersModule =
SerializersModule { SerializersModule {
contextual(UUID::class) { UUIDSerializer } contextual(UUID::class) { UUIDSerializer }
contextual(GameId::class) { GameIdSerializer } contextual(GameId::class) { GameIdSerializer }
contextual(CommandId::class) { CommandIdSerializer } contextual(CommandId::class) { CommandIdSerializer }
contextual(Player.PlayerId::class) { PlayerIdSerializer } contextual(Player.PlayerId::class) { PlayerIdSerializer }
} }
} }
object CommandIdSerializer : KSerializer<CommandId> { object CommandIdSerializer : KSerializer<CommandId> {
override fun deserialize(decoder: Decoder): CommandId = CommandId(decoder.decodeString()) override fun deserialize(decoder: Decoder): CommandId =
CommandId(decoder.decodeString())
override fun serialize( override fun serialize(
encoder: Encoder, encoder: Encoder,
value: CommandId, value: CommandId,
) { ) {
encoder.encodeString(value.toString()) encoder.encodeString(value.toString())
} }
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING)
} }
object PlayerIdSerializer : KSerializer<Player.PlayerId> { object PlayerIdSerializer : KSerializer<Player.PlayerId> {
override fun deserialize(decoder: Decoder): Player.PlayerId = Player.PlayerId(UUID.fromString(decoder.decodeString())) override fun deserialize(decoder: Decoder): Player.PlayerId =
Player.PlayerId(UUID.fromString(decoder.decodeString()))
override fun serialize( override fun serialize(
encoder: Encoder, encoder: Encoder,
value: Player.PlayerId, value: Player.PlayerId,
) { ) {
encoder.encodeString(value.id.toString()) encoder.encodeString(value.id.toString())
} }
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PlayerId", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PlayerId", PrimitiveKind.STRING)
} }
object GameIdSerializer : KSerializer<GameId> { object GameIdSerializer : KSerializer<GameId> {
override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString())) override fun deserialize(decoder: Decoder): GameId =
GameId(UUID.fromString(decoder.decodeString()))
override fun serialize( override fun serialize(
encoder: Encoder, encoder: Encoder,
value: GameId, value: GameId,
) { ) {
encoder.encodeString(value.id.toString()) encoder.encodeString(value.id.toString())
} }
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING)
} }
object UUIDSerializer : KSerializer<UUID> { object UUIDSerializer : KSerializer<UUID> {
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) override fun deserialize(decoder: Decoder): UUID =
UUID.fromString(decoder.decodeString())
override fun serialize( override fun serialize(
encoder: Encoder, encoder: Encoder,
value: UUID, value: UUID,
) { ) {
encoder.encodeString(value.toString()) encoder.encodeString(value.toString())
} }
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
} }

View File

@@ -8,10 +8,10 @@ import io.ktor.server.websocket.timeout
import java.time.Duration import java.time.Duration
fun Application.configureWebSockets() { fun Application.configureWebSockets() {
install(WebSockets) { install(WebSockets) {
pingPeriod = Duration.ofSeconds(15) pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15) timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE maxFrameSize = Long.MAX_VALUE
masking = false masking = false
} }
} }

View File

@@ -9,10 +9,10 @@ import kotlinx.coroutines.DelicateCoroutinesApi
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun Application.declareWebSocketsGameRoute( fun Application.declareWebSocketsGameRoute(
playerNotificationListener: PlayerNotificationEventListener, playerNotificationListener: PlayerNotificationEventListener,
commandHandler: GameCommandHandler, commandHandler: GameCommandHandler,
) { ) {
routing { routing {
gameSocket(playerNotificationListener, commandHandler) gameSocket(playerNotificationListener, commandHandler)
} }
} }

View File

@@ -6,7 +6,7 @@ import io.ktor.server.routing.routing
import org.koin.ktor.ext.get import org.koin.ktor.ext.get
fun Application.declareHttpGameRoute() { fun Application.declareHttpGameRoute() {
routing { routing {
readTheGameState(get()) readTheGameState(get())
} }
} }

View File

@@ -16,28 +16,28 @@ import kotlinx.serialization.json.Json
@OptIn(ExperimentalCoroutinesApi::class, InternalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class, InternalCoroutinesApi::class)
inline fun <reified T> CoroutineScope.toObjectChannel( inline fun <reified T> CoroutineScope.toObjectChannel(
frames: ReceiveChannel<Frame>, frames: ReceiveChannel<Frame>,
bufferSize: Int = 0, bufferSize: Int = 0,
): ReceiveChannel<T> { ): ReceiveChannel<T> {
val logger = KotlinLogging.logger { } val logger = KotlinLogging.logger { }
return produce(capacity = bufferSize) { return produce(capacity = bufferSize) {
frames.consumeEach { frame -> frames.consumeEach { frame ->
if (frame is Frame.Text) { if (frame is Frame.Text) {
logger.debug { "Conversion of the Frame: ${frame.readText()}" } logger.debug { "Conversion of the Frame: ${frame.readText()}" }
send(Json.decodeFromString(frame.readText())) send(Json.decodeFromString(frame.readText()))
} else { } else {
logger.warn { "The frame is not a text frame" } logger.warn { "The frame is not a text frame" }
} }
}
} }
}
} }
inline fun <reified T> CoroutineScope.fromFrameChannel(frames: SendChannel<Frame>): SendChannel<T> { inline fun <reified T> CoroutineScope.fromFrameChannel(frames: SendChannel<Frame>): SendChannel<T> {
val channel = Channel<T>() val channel = Channel<T>()
launch { launch {
channel.consumeEach { obj -> channel.consumeEach { obj ->
frames.send(Frame.Text(Json.encodeToString(obj))) frames.send(Frame.Text(Json.encodeToString(obj)))
}
} }
return channel }
return channel
} }

View File

@@ -10,11 +10,12 @@ import java.util.UUID
@JvmInline @JvmInline
@Serializable(with = CommandIdSerializer::class) @Serializable(with = CommandIdSerializer::class)
value class CommandId( value class CommandId(
private val id: UUID = UUID.randomUUID(), private val id: UUID = UUID.randomUUID(),
) { ) {
constructor(id: String) : this(UUID.fromString(id)) constructor(id: String) : this(UUID.fromString(id))
override fun toString(): String = id.toString() override fun toString(): String =
id.toString()
} }
/** /**
@@ -23,5 +24,5 @@ value class CommandId(
* A command is a request for an action. * A command is a request for an action.
*/ */
interface Command { interface Command {
val id: CommandId val id: CommandId
} }

View File

@@ -10,26 +10,26 @@ import kotlinx.coroutines.launch
* 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> {
/** /**
* A class to implement success/failed action. * A class to implement success/failed action.
*/ */
interface ComputeStatus { interface ComputeStatus {
suspend fun ack() suspend fun ack()
suspend fun nack() suspend fun nack()
} }
/** /**
* Apply an action to all command income in the stream. * Apply an action to all command income in the stream.
*/ */
suspend fun process(action: CommandBlock<C>) suspend fun process(action: CommandBlock<C>)
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun blockAndProcess(action: CommandBlock<C>) { fun blockAndProcess(action: CommandBlock<C>) {
GlobalScope.launch { GlobalScope.launch {
process(action) process(action)
}
} }
}
} }
typealias CommandBlock<C> = suspend CommandStream.ComputeStatus.(C) -> Unit typealias CommandBlock<C> = suspend CommandStream.ComputeStatus.(C) -> Unit

View File

@@ -9,87 +9,88 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
class CommandStreamChannelBuilder<C : Command>( class CommandStreamChannelBuilder<C : Command>(
private val maxCacheTime: Duration = 10.minutes, private val maxCacheTime: Duration = 10.minutes,
) { ) {
operator fun invoke(incoming: ReceiveChannel<C>): CommandStreamChannel<C> = CommandStreamChannel(incoming, maxCacheTime) operator fun invoke(incoming: ReceiveChannel<C>): CommandStreamChannel<C> =
CommandStreamChannel(incoming, maxCacheTime)
} }
/** /**
* Manage [Command]'s with kotlin Channel * Manage [Command]'s with kotlin Channel
*/ */
class CommandStreamChannel<C : Command>( class CommandStreamChannel<C : Command>(
private val incoming: ReceiveChannel<C>, private val incoming: ReceiveChannel<C>,
private val maxCacheTime: Duration = 10.minutes, private val maxCacheTime: Duration = 10.minutes,
) : CommandStream<C> { ) : CommandStream<C> {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val executedCommand: ConcurrentHashMap<CommandId, Pair<Boolean, Instant>> = ConcurrentHashMap() private val executedCommand: ConcurrentHashMap<CommandId, Pair<Boolean, Instant>> = ConcurrentHashMap()
override suspend fun process(action: CommandBlock<C>) { override suspend fun process(action: CommandBlock<C>) {
for (command in incoming) { for (command in incoming) {
val now = Clock.System.now() val now = Clock.System.now()
val (status, _) = executedCommand.computeIfAbsent(command.id) { Pair(false, now) } val (status, _) = executedCommand.computeIfAbsent(command.id) { Pair(false, now) }
if (status) { if (status) {
logger.atWarn {
message = "Command already executed: $command"
payload = mapOf("command" to command)
}
} else {
compute(command, action)
}
executedCommand
.filterValues { (_, date) ->
(date + maxCacheTime) > now
}.keys
.forEach {
executedCommand.remove(it)
}
}
}
private suspend fun compute(
command: C,
action: CommandBlock<C>,
) {
val status =
object : CommandStream.ComputeStatus {
var isSet: Boolean = false
override suspend fun ack() {
if (!isSet) markAsSuccess(command) else error("Already NACK")
isSet = true
}
override suspend fun nack() {
if (!isSet) markAsFailed(command) else error("Already ACK")
isSet = true
}
}
val actionResult = runCatching { status.action(command) }
if (actionResult.isFailure) {
logger.atInfo {
message = "Error on compute the Command: $command"
payload = mapOf("command" to command)
cause = actionResult.exceptionOrNull()
}
markAsFailed(command)
} else if (!status.isSet) {
status.ack()
}
}
private suspend fun markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS: $command"
payload = mapOf("command" to command)
}
}
private suspend fun markAsFailed(command: C) {
logger.atWarn { logger.atWarn {
message = "Compute command FAILED: $command" message = "Command already executed: $command"
payload = mapOf("command" to command) payload = mapOf("command" to command)
}
} else {
compute(command, action)
}
executedCommand
.filterValues { (_, date) ->
(date + maxCacheTime) > now
}.keys
.forEach {
executedCommand.remove(it)
} }
} }
}
private suspend fun compute(
command: C,
action: CommandBlock<C>,
) {
val status =
object : CommandStream.ComputeStatus {
var isSet: Boolean = false
override suspend fun ack() {
if (!isSet) markAsSuccess(command) else error("Already NACK")
isSet = true
}
override suspend fun nack() {
if (!isSet) markAsFailed(command) else error("Already ACK")
isSet = true
}
}
val actionResult = runCatching { status.action(command) }
if (actionResult.isFailure) {
logger.atInfo {
message = "Error on compute the Command: $command"
payload = mapOf("command" to command)
cause = actionResult.exceptionOrNull()
}
markAsFailed(command)
} else if (!status.isSet) {
status.ack()
}
}
private suspend fun markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS: $command"
payload = mapOf("command" to command)
}
}
private suspend fun markAsFailed(command: C) {
logger.atWarn {
message = "Compute command FAILED: $command"
payload = mapOf("command" to command)
}
}
} }

View File

@@ -8,7 +8,7 @@ import java.util.UUID
* @see Event * @see Event
*/ */
interface AggregateId { interface AggregateId {
val id: UUID val id: UUID
} }
/** /**
@@ -16,8 +16,8 @@ interface AggregateId {
* @see EventStream * @see EventStream
*/ */
interface Event<ID : AggregateId> { interface Event<ID : AggregateId> {
val eventId: UUID val eventId: UUID
val aggregateId: ID val aggregateId: ID
val createdAt: Instant val createdAt: Instant
val version: Int val version: Int
} }

View File

@@ -1,13 +1,13 @@
package eventDemo.libs.event 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)
/** /**
* @param priority The higher the priority, the more it will be called first * @param priority The higher the priority, the more it will be called first
*/ */
fun subscribe( fun subscribe(
priority: Int = 0, priority: Int = 0,
block: suspend (E) -> Unit, block: suspend (E) -> Unit,
) )
} }

View File

@@ -3,22 +3,22 @@ package eventDemo.libs.event
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class EventBusInMemory<E : Event<ID>, ID : AggregateId> : EventBus<E, ID> { class EventBusInMemory<E : Event<ID>, ID : AggregateId> : EventBus<E, ID> {
private val subscribers: MutableList<Pair<Int, suspend (E) -> Unit>> = mutableListOf() private val subscribers: MutableList<Pair<Int, suspend (E) -> Unit>> = mutableListOf()
override fun publish(event: E) { override fun publish(event: E) {
subscribers subscribers
.sortedByDescending { (priority, _) -> priority } .sortedByDescending { (priority, _) -> priority }
.forEach { (_, block) -> .forEach { (_, block) ->
runBlocking { runBlocking {
block(event) block(event)
} }
} }
} }
override fun subscribe( override fun subscribe(
priority: Int, priority: Int,
block: suspend (E) -> Unit, block: suspend (E) -> Unit,
) { ) {
subscribers.add(priority to block) subscribers.add(priority to block)
} }
} }

View File

@@ -1,7 +1,7 @@
package eventDemo.libs.event package eventDemo.libs.event
interface EventStore<E : Event<ID>, ID : AggregateId> { interface EventStore<E : Event<ID>, ID : AggregateId> {
fun getStream(aggregateId: ID): EventStream<E> fun getStream(aggregateId: ID): EventStream<E>
fun publish(event: E) fun publish(event: E)
} }

View File

@@ -4,9 +4,11 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap import java.util.concurrent.ConcurrentMap
class EventStoreInMemory<E : Event<ID>, ID : AggregateId> : EventStore<E, ID> { class EventStoreInMemory<E : Event<ID>, ID : AggregateId> : EventStore<E, ID> {
private val streams: ConcurrentMap<ID, EventStream<E>> = ConcurrentHashMap() private val streams: ConcurrentMap<ID, EventStream<E>> = ConcurrentHashMap()
override fun getStream(aggregateId: ID): EventStream<E> = streams.computeIfAbsent(aggregateId) { EventStreamInMemory() } override fun getStream(aggregateId: ID): EventStream<E> =
streams.computeIfAbsent(aggregateId) { EventStreamInMemory() }
override fun publish(event: E) = getStream(event.aggregateId).publish(event) override fun publish(event: E) =
getStream(event.aggregateId).publish(event)
} }

View File

@@ -4,16 +4,16 @@ package eventDemo.libs.event
* Interface representing an event stream for publishing and reading domain events * Interface representing an event stream for publishing and reading domain events
*/ */
interface EventStream<E : Event<*>> { interface EventStream<E : Event<*>> {
/** Publishes a single event to the event stream */ /** Publishes a single event to the event stream */
fun publish(event: E) fun publish(event: E)
/** Publishes multiple events to the event stream */ /** Publishes multiple events to the event stream */
fun publish(vararg events: E) fun publish(vararg events: E)
/** Reads all events */ /** Reads all events */
fun readAll(): Set<E> fun readAll(): Set<E>
fun readGreaterOfVersion(version: Int): Set<E> fun readGreaterOfVersion(version: Int): Set<E>
fun readVersionBetween(version: IntRange): Set<E> fun readVersionBetween(version: IntRange): Set<E>
} }

View File

@@ -10,32 +10,33 @@ import java.util.concurrent.ConcurrentLinkedQueue
* All methods are implemented. * All methods are implemented.
*/ */
class EventStreamInMemory<E : Event<*>> : EventStream<E> { class EventStreamInMemory<E : Event<*>> : EventStream<E> {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val events: Queue<E> = ConcurrentLinkedQueue() private val events: Queue<E> = ConcurrentLinkedQueue()
override fun publish(event: E) { override fun publish(event: E) {
if (events.none { it.eventId == event.eventId }) { if (events.none { it.eventId == event.eventId }) {
events.add(event) events.add(event)
logger.atInfo { logger.atInfo {
message = "Event published: $event" message = "Event published: $event"
payload = mapOf("event" to event) payload = mapOf("event" to event)
} }
}
} }
}
override fun publish(vararg events: E) { override fun publish(vararg events: E) {
events.forEach { publish(it) } events.forEach { publish(it) }
} }
override fun readAll(): Set<E> = events.toSet() override fun readAll(): Set<E> =
events.toSet()
override fun readGreaterOfVersion(version: Int): Set<E> = override fun readGreaterOfVersion(version: Int): Set<E> =
events events
.filter { it.version > version } .filter { it.version > version }
.toSet() .toSet()
override fun readVersionBetween(version: IntRange): Set<E> = override fun readVersionBetween(version: IntRange): Set<E> =
events events
.filter { version.contains(it.version) } .filter { version.contains(it.version) }
.toSet() .toSet()
} }

View File

@@ -1,7 +1,7 @@
package eventDemo.libs.event package eventDemo.libs.event
interface VersionBuilder { interface VersionBuilder {
fun buildNextVersion(aggregateId: AggregateId): Int fun buildNextVersion(aggregateId: AggregateId): Int
fun getLastVersion(aggregateId: AggregateId): Int fun getLastVersion(aggregateId: AggregateId): Int
} }

View File

@@ -5,17 +5,18 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
class VersionBuilderLocal : VersionBuilder { class VersionBuilderLocal : VersionBuilder {
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
private val versions: ConcurrentHashMap<AggregateId, AtomicInteger> = ConcurrentHashMap() private val versions: ConcurrentHashMap<AggregateId, AtomicInteger> = ConcurrentHashMap()
override fun buildNextVersion(aggregateId: AggregateId): Int = override fun buildNextVersion(aggregateId: AggregateId): Int =
versionOfAggregate(aggregateId) versionOfAggregate(aggregateId)
.addAndGet(1) .addAndGet(1)
.also { logger.debug { "New version $it" } } .also { logger.debug { "New version $it" } }
override fun getLastVersion(aggregateId: AggregateId): Int = versionOfAggregate(aggregateId).toInt() override fun getLastVersion(aggregateId: AggregateId): Int =
versionOfAggregate(aggregateId).toInt()
private fun versionOfAggregate(aggregateId: AggregateId) = private fun versionOfAggregate(aggregateId: AggregateId) =
versions versions
.computeIfAbsent(aggregateId) { AtomicInteger(0) } .computeIfAbsent(aggregateId) { AtomicInteger(0) }
} }

View File

@@ -3,8 +3,10 @@ package eventDemo
import eventDemo.app.entity.Card import eventDemo.app.entity.Card
import eventDemo.app.entity.Deck import eventDemo.app.entity.Deck
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))) // suspend fun SendChannel<Frame>.send(command: GameCommand) = send(Frame.Text(Json.encodeToString(command)))

View File

@@ -20,26 +20,26 @@ import kotlin.test.assertIs
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class GameCommandHandlerTest : class GameCommandHandlerTest :
FunSpec({ FunSpec({
test("handle a command should execute the command") { test("handle a command should execute the command") {
koinApplication { modules(appKoinModule) }.koin.apply { koinApplication { modules(appKoinModule) }.koin.apply {
val commandHandler by inject<GameCommandHandler>() val commandHandler by inject<GameCommandHandler>()
val notificationListener by inject<PlayerNotificationEventListener>() val notificationListener by inject<PlayerNotificationEventListener>()
val gameId = GameId() val gameId = GameId()
val player = Player("Tesla") val player = Player("Tesla")
val channelCommand = Channel<GameCommand>(Channel.BUFFERED) val channelCommand = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification = Channel<Notification>(Channel.BUFFERED) val channelNotification = Channel<Notification>(Channel.BUFFERED)
ReactionEventListener(get(), get(), get()).init() ReactionEventListener(get(), get(), get()).init()
notificationListener.startListening(channelNotification, player) notificationListener.startListening(channelNotification, player)
GlobalScope.launch { GlobalScope.launch {
commandHandler.handle(player, channelCommand, channelNotification) commandHandler.handle(player, channelCommand, channelNotification)
}
channelCommand.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)))
assertIs<WelcomeToTheGameNotification>(channelNotification.receive()).let {
it.players shouldContain player
}
}
} }
})
channelCommand.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)))
assertIs<WelcomeToTheGameNotification>(channelNotification.receive()).let {
it.players shouldContain player
}
}
}
})

View File

@@ -3,6 +3,6 @@ package eventDemo.app.command
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class GameCommandRunnerTest : class GameCommandRunnerTest :
FunSpec({ FunSpec({
test("run should run the correct command") { } test("run should run the correct command") { }
}) })

View File

@@ -3,7 +3,7 @@ package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class ICantPlayCommandTest : class ICantPlayCommandTest :
FunSpec({ FunSpec({
xtest("run should publish the event") { } xtest("run should publish the event") { }
}) })

View File

@@ -3,7 +3,7 @@ package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class IWantToJoinTheGameCommandTest : class IWantToJoinTheGameCommandTest :
FunSpec({ FunSpec({
xtest("run should publish the event") { } xtest("run should publish the event") { }
}) })

View File

@@ -3,7 +3,7 @@ package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class IWantToPlayCardCommandTest : class IWantToPlayCardCommandTest :
FunSpec({ FunSpec({
xtest("run should publish the event") { } xtest("run should publish the event") { }
}) })

View File

@@ -3,7 +3,7 @@ package eventDemo.app.command.command
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class IamReadyToPlayCommandTest : class IamReadyToPlayCommandTest :
FunSpec({ FunSpec({
xtest("run should publish the event") { } xtest("run should publish the event") { }
}) })

View File

@@ -8,97 +8,97 @@ import io.kotest.matchers.ints.shouldBeExactly
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
class DeckTest : class DeckTest :
FunSpec({ FunSpec({
val totalCardsNumber = 104 val totalCardsNumber = 104
test("newWithoutPlayers") { test("newWithoutPlayers") {
// When // When
val deck = Deck.newWithoutPlayers() val deck = Deck.newWithoutPlayers()
// Then // Then
deck.stack.size shouldBeExactly totalCardsNumber deck.stack.size shouldBeExactly totalCardsNumber
deck.discard.size shouldBeExactly 0 deck.discard.size shouldBeExactly 0
deck.playersHands.size shouldBeExactly 0 deck.playersHands.size shouldBeExactly 0
deck.allCardCount() shouldBeExactly totalCardsNumber deck.allCardCount() shouldBeExactly totalCardsNumber
deck.allCards().shouldBeUnique() deck.allCards().shouldBeUnique()
deck.allCards().map { it.id }.shouldBeUnique() deck.allCards().map { it.id }.shouldBeUnique()
} }
test("initHands should be generate the hands of all players from the stack") { test("initHands should be generate the hands of all players from the stack") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val deck = Deck.newWithoutPlayers() val deck = Deck.newWithoutPlayers()
// When // When
val initDeck = deck.initHands(players) val initDeck = deck.initHands(players)
// Then // Then
initDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7) initDeck.stack.size shouldBeExactly totalCardsNumber - (playerNumbers * 7)
initDeck.discard.size shouldBeExactly 0 initDeck.discard.size shouldBeExactly 0
initDeck.playersHands.size shouldBeExactly playerNumbers initDeck.playersHands.size shouldBeExactly playerNumbers
initDeck.playersHands.forEach { (_, cards) -> cards.size shouldBeExactly 7 } initDeck.playersHands.forEach { (_, cards) -> cards.size shouldBeExactly 7 }
initDeck.allCardCount() shouldBeExactly totalCardsNumber initDeck.allCardCount() shouldBeExactly totalCardsNumber
} }
test("takeOneCardFromStackTo player") { test("takeOneCardFromStackTo player") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val deck = Deck.newWithoutPlayers().initHands(players) val deck = Deck.newWithoutPlayers().initHands(players)
val firstPlayer = players.first() val firstPlayer = players.first()
// When // When
val modifiedDeck = deck.takeOneCardFromStackTo(firstPlayer) val modifiedDeck = deck.takeOneCardFromStackTo(firstPlayer)
// Then // Then
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.getHand(firstPlayer)).size shouldBeExactly 7 + 1 assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 7 + 1
modifiedDeck.playersHands modifiedDeck.playersHands
.filterKeys { it != firstPlayer.id } .filterKeys { it != firstPlayer.id }
.forEach { (_, cards) -> cards.size shouldBeExactly 7 } .forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
} }
test("putOneCardFromHand") { test("putOneCardFromHand") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val deck = Deck.newWithoutPlayers().initHands(players) val deck = Deck.newWithoutPlayers().initHands(players)
val firstPlayer = players.first() val firstPlayer = players.first()
// When // When
val card = deck.playersHands.getHand(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.getHand(firstPlayer)).size shouldBeExactly 6 assertNotNull(modifiedDeck.playersHands.getHand(firstPlayer)).size shouldBeExactly 6
modifiedDeck.playersHands modifiedDeck.playersHands
.filterKeys { it != firstPlayer.id } .filterKeys { it != firstPlayer.id }
.forEach { (_, cards) -> cards.size shouldBeExactly 7 } .forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
} }
test("placeFirstCardOnDiscard") { test("placeFirstCardOnDiscard") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val deck = Deck.newWithoutPlayers().initHands(players) val deck = Deck.newWithoutPlayers().initHands(players)
// When // When
val modifiedDeck = deck.placeFirstCardOnDiscard() val modifiedDeck = deck.placeFirstCardOnDiscard()
// Then // Then
modifiedDeck.discard.size shouldBeExactly 1 modifiedDeck.discard.size shouldBeExactly 1
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
modifiedDeck.playersHands modifiedDeck.playersHands
.forEach { (_, cards) -> cards.size shouldBeExactly 7 } .forEach { (_, cards) -> cards.size shouldBeExactly 7 }
modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber modifiedDeck.allCardCount() shouldBeExactly totalCardsNumber
} }
}) })

View File

@@ -5,37 +5,37 @@ import io.kotest.matchers.ints.shouldBeExactly
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
class PlayerHandKtTest : class PlayerHandKtTest :
FunSpec({ FunSpec({
test("addCards") { test("addCards") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val firstPlayer = players.first() val firstPlayer = players.first()
val playersHands = PlayersHands(players) val playersHands = PlayersHands(players)
val card = Card.NumericCard(0, Card.Color.Red) val card = Card.NumericCard(0, Card.Color.Red)
// When // When
val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card)) val newHands: PlayersHands = playersHands.addCards(firstPlayer, listOf(card))
assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1
assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0
} }
test("removeCard") { test("removeCard") {
// Given // Given
val playerNumbers = 4 val playerNumbers = 4
val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet() val players = (1..playerNumbers).map { Player(name = "name $it") }.toSet()
val firstPlayer = players.first() val firstPlayer = players.first()
val card1 = Card.NumericCard(1, Card.Color.Red) val card1 = Card.NumericCard(1, Card.Color.Red)
val card2 = Card.NumericCard(2, Card.Color.Red) val card2 = Card.NumericCard(2, Card.Color.Red)
val playersHands: PlayersHands = val playersHands: PlayersHands =
PlayersHands(players) PlayersHands(players)
.addCards(firstPlayer, listOf(card1, card2)) .addCards(firstPlayer, listOf(card1, card2))
// When // When
val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1) val newHands: PlayersHands = playersHands.removeCard(firstPlayer, card1)
assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1 assertNotNull(newHands.getHand(firstPlayer)).size shouldBeExactly 1
assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0 assertNotNull(newHands.getHand(players.last())).size shouldBeExactly 0
} }
}) })

View File

@@ -3,13 +3,13 @@ package eventDemo.app.entity
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class PlayersHandsTest : class PlayersHandsTest :
FunSpec({ FunSpec({
xtest("getHand should return the hand of the player") { } xtest("getHand should return the hand of the player") { }
xtest("removeCard should remove the card") { } xtest("removeCard should remove the card") { }
xtest("addCard should add the card to the correct hand") { } xtest("addCard should add the card to the correct hand") { }
xtest("toPlayersHands should build object from map") { } xtest("toPlayersHands should build object from map") { }
}) })

View File

@@ -3,8 +3,8 @@ package eventDemo.app.event
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class GameEventHandlerTest : class GameEventHandlerTest :
FunSpec({ FunSpec({
xtest("handle event should publish the event to the stream") { } xtest("handle event should publish the event to the stream") { }
xtest("handle event should build the registered projection") { } xtest("handle event should build the registered projection") { }
xtest("handle event should publish the event to the bus") { } xtest("handle event should publish the event to the bus") { }
}) })

View File

@@ -15,112 +15,112 @@ import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
class GameStateBuilderTest : class GameStateBuilderTest :
FunSpec({ FunSpec({
test("apply") { test("apply") {
disableShuffleDeck() disableShuffleDeck()
val versionBuilder = VersionBuilderLocal() val versionBuilder = VersionBuilderLocal()
val gameId = GameId() val gameId = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein") val player2 = Player(name = "Einstein")
GameState(gameId) GameState(gameId)
.run { .run {
val event = val event =
NewPlayerEvent( NewPlayerEvent(
aggregateId = gameId, aggregateId = gameId,
player = player1, player = player1,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.isReady shouldBeEqual false state.isReady shouldBeEqual false
state.isStarted shouldBeEqual false state.isStarted shouldBeEqual false
} }
}.run { }.run {
val event = val event =
NewPlayerEvent( NewPlayerEvent(
aggregateId = gameId, aggregateId = gameId,
player = player2, player = player2,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.players shouldBeEqual setOf(player1, player2) state.players shouldBeEqual setOf(player1, player2)
} }
}.run { }.run {
val event = val event =
PlayerReadyEvent( PlayerReadyEvent(
aggregateId = gameId, aggregateId = gameId,
player = player1, player = player1,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.readyPlayers shouldBeEqual setOf(player1) state.readyPlayers shouldBeEqual setOf(player1)
} }
}.run { }.run {
val event = val event =
PlayerReadyEvent( PlayerReadyEvent(
aggregateId = gameId, aggregateId = gameId,
player = player2, player = player2,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.readyPlayers shouldBeEqual setOf(player1, player2) state.readyPlayers shouldBeEqual setOf(player1, player2)
state.isReady shouldBeEqual true state.isReady shouldBeEqual true
state.isStarted shouldBeEqual false state.isStarted shouldBeEqual false
} }
}.run { }.run {
val event = val event =
GameStartedEvent.new( GameStartedEvent.new(
id = gameId, id = gameId,
players = setOf(player1, player2), players = setOf(player1, player2),
shuffleIsDisabled = true, shuffleIsDisabled = true,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.isStarted shouldBeEqual true state.isStarted shouldBeEqual true
assertIs<Card.NumericCard>(state.deck.stack.first()).let { assertIs<Card.NumericCard>(state.deck.stack.first()).let {
it.number shouldBeEqual 6 it.number shouldBeEqual 6
it.color shouldBeEqual Card.Color.Red it.color shouldBeEqual Card.Color.Red
} }
} }
}.run { }.run {
val playedCard = playableCards(player1)[0] val playedCard = playableCards(player1)[0]
val event = val event =
CardIsPlayedEvent( CardIsPlayedEvent(
aggregateId = gameId, aggregateId = gameId,
card = playedCard, card = playedCard,
player = player1, player = player1,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard
assertIs<Card.NumericCard>(playedCard).let { assertIs<Card.NumericCard>(playedCard).let {
it.number shouldBeEqual 0 it.number shouldBeEqual 0
it.color shouldBeEqual Card.Color.Red it.color shouldBeEqual Card.Color.Red
} }
} }
}.run { }.run {
val playedCard = playableCards(player2)[0] val playedCard = playableCards(player2)[0]
val event = val event =
CardIsPlayedEvent( CardIsPlayedEvent(
aggregateId = gameId, aggregateId = gameId,
card = playedCard, card = playedCard,
player = player2, player = player2,
version = versionBuilder.buildNextVersion(gameId), version = versionBuilder.buildNextVersion(gameId),
) )
apply(event).also { state -> apply(event).also { state ->
state.aggregateId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard
assertIs<Card.NumericCard>(playedCard).let { assertIs<Card.NumericCard>(playedCard).let {
it.number shouldBeEqual 7 it.number shouldBeEqual 7
it.color shouldBeEqual Card.Color.Red it.color shouldBeEqual Card.Color.Red
} }
} }
}
} }
}) }
})

View File

@@ -18,113 +18,113 @@ import kotlin.test.assertNotNull
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class GameStateRepositoryTest : class GameStateRepositoryTest :
FunSpec({ FunSpec({
val player1 = Player("Tesla") val player1 = Player("Tesla")
val player2 = Player(name = "Einstein") val player2 = Player(name = "Einstein")
test("GameStateRepository should build the projection when a new event occurs") { test("GameStateRepository should build the projection when a new event occurs") {
val aggregateId = GameId() val aggregateId = GameId()
koinApplication { modules(appKoinModule) }.koin.apply { koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>() val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>() val eventHandler = get<GameEventHandler>()
eventHandler eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) } .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also { event -> .also { event ->
assertNotNull(repo.getUntil(event)).also { assertNotNull(repo.getUntil(event)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1) assertNotNull(it.players) shouldBeEqual setOf(player1)
}
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
} }
stopKoin() assertNotNull(repo.getLast(aggregateId)).also {
} assertNotNull(it.players) shouldBeEqual setOf(player1)
test("get should build the last version of the state") {
val aggregateId = GameId()
koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also {
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also {
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
}
}
} }
} }
}
stopKoin()
}
test("getUntil should build the state until the event") { test("get should build the last version of the state") {
repeat(10) { val aggregateId = GameId()
val aggregateId = GameId() koinApplication { modules(appKoinModule) }.koin.apply {
koinApplication { modules(appKoinModule) }.koin.apply { val repo = get<GameStateRepository>()
val repo = get<GameStateRepository>() val eventHandler = get<GameEventHandler>()
val eventHandler = get<GameEventHandler>()
val event1 = eventHandler
eventHandler .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) } .also {
.also { event1 -> assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(repo.getUntil(event1)).also { assertNotNull(it.players) shouldBeEqual setOf(player1)
assertNotNull(it.players) shouldBeEqual setOf(player1) }
} }
}
eventHandler eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) } .handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also { event2 -> .also {
assertNotNull(repo.getUntil(event2)).also { assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2) assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
} }
assertNotNull(repo.getUntil(event1)).also { }
assertNotNull(it.players) shouldBeEqual setOf(player1) }
} }
}
test("getUntil should build the state until the event") {
repeat(10) {
val aggregateId = GameId()
koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
val event1 =
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also { event1 ->
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
} }
}
eventHandler
.handle(aggregateId) { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also { event2 ->
assertNotNull(repo.getUntil(event2)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
}
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
} }
} }
}
}
test("getUntil should be concurrently secure") { test("getUntil should be concurrently secure") {
val aggregateId = GameId() val aggregateId = GameId()
koinApplication { modules(appKoinModule) }.koin.apply { koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>() val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>() val eventHandler = get<GameEventHandler>()
(1..10) (1..10)
.map { r -> .map { r ->
GlobalScope GlobalScope
.launch { .launch {
repeat(100) { r2 -> repeat(100) { r2 ->
val playerX = Player("player$r$r2") val playerX = Player("player$r$r2")
eventHandler eventHandler
.handle(aggregateId) { .handle(aggregateId) {
NewPlayerEvent( NewPlayerEvent(
aggregateId = aggregateId, aggregateId = aggregateId,
player = playerX, player = playerX,
version = it, version = it,
) )
} }
}
}
}.joinAll()
repo.getLast(aggregateId).run {
lastEventVersion shouldBeEqual 1000
players shouldHaveSize 1000
} }
} }
} }.joinAll()
xtest("get should be concurrently secure") { } repo.getLast(aggregateId).run {
}) lastEventVersion shouldBeEqual 1000
players shouldHaveSize 1000
}
}
}
xtest("get should be concurrently secure") { }
})

View File

@@ -3,13 +3,13 @@ package eventDemo.app.event.projection
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class GameStateTest : class GameStateTest :
FunSpec({ FunSpec({
xtest("isReady") { } xtest("isReady") { }
xtest("nextPlayer") { } xtest("nextPlayer") { }
xtest("nextPlayerTurn") { } xtest("nextPlayerTurn") { }
xtest("playerDiffIndex") { } xtest("playerDiffIndex") { }
xtest("cardOnBoardIsForYou") { } xtest("cardOnBoardIsForYou") { }
xtest("playableCards") { } xtest("playableCards") { }
xtest("playerHasNoCardLeft") { } xtest("playerHasNoCardLeft") { }
xtest("canBePlayThisCard") { } xtest("canBePlayThisCard") { }
}) })

View File

@@ -20,151 +20,151 @@ import kotlin.test.assertNotNull
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class ProjectionSnapshotRepositoryInMemoryTest : class ProjectionSnapshotRepositoryInMemoryTest :
FunSpec({ FunSpec({
test("when call applyAndPutToCache, the getUntil method must be use the built projection cache") { test("when call applyAndPutToCache, the getUntil method must be use the built projection cache") {
val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory() val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
val repo = getSnapshotRepoTest(eventStore) val repo = getSnapshotRepoTest(eventStore)
val aggregateId = IdTest() val aggregateId = IdTest()
val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = IdTest()) val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = IdTest())
eventStore.publish(eventOther) eventStore.publish(eventOther)
repo.applyAndPutToCache(eventOther) repo.applyAndPutToCache(eventOther)
assertNotNull(repo.getUntil(eventOther)).also { assertNotNull(repo.getUntil(eventOther)).also {
assertNotNull(it.value) shouldBeEqual "valOther" assertNotNull(it.value) shouldBeEqual "valOther"
} }
val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId) val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId)
eventStore.publish(event1) eventStore.publish(event1)
repo.applyAndPutToCache(event1) repo.applyAndPutToCache(event1)
assertNotNull(repo.getLast(event1.aggregateId)).also { assertNotNull(repo.getLast(event1.aggregateId)).also {
assertNotNull(it.value) shouldBeEqual "val1" assertNotNull(it.value) shouldBeEqual "val1"
} }
assertNotNull(repo.getUntil(event1)).also { assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.value) shouldBeEqual "val1" assertNotNull(it.value) shouldBeEqual "val1"
} }
val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId) val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId)
eventStore.publish(event2) eventStore.publish(event2)
repo.applyAndPutToCache(event2) repo.applyAndPutToCache(event2)
assertNotNull(repo.getLast(event2.aggregateId)).also { assertNotNull(repo.getLast(event2.aggregateId)).also {
assertNotNull(it.value) shouldBeEqual "val1val2" assertNotNull(it.value) shouldBeEqual "val1val2"
} }
assertNotNull(repo.getUntil(event1)).also { assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.value) shouldBeEqual "val1" assertNotNull(it.value) shouldBeEqual "val1"
} }
assertNotNull(repo.getUntil(event2)).also { assertNotNull(repo.getUntil(event2)).also {
assertNotNull(it.value) shouldBeEqual "val1val2" assertNotNull(it.value) shouldBeEqual "val1val2"
} }
} }
test("ProjectionSnapshotRepositoryInMemory should be thread safe") { test("ProjectionSnapshotRepositoryInMemory should be thread safe") {
val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory() val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
val repo = getSnapshotRepoTest(eventStore) val repo = getSnapshotRepoTest(eventStore)
val aggregateId = IdTest() val aggregateId = IdTest()
val versionBuilder = VersionBuilderLocal() val versionBuilder = VersionBuilderLocal()
val lock = ReentrantLock() val lock = ReentrantLock()
(0..9) (0..9)
.map { .map {
GlobalScope.launch { GlobalScope.launch {
(1..10).map { (1..10).map {
val eventX = val eventX =
lock.withLock { lock.withLock {
EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId) EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
.also { eventStore.publish(it) }
}
repo.applyAndPutToCache(eventX)
}
}
}.joinAll()
assertNotNull(repo.getLast(aggregateId)).num shouldBeEqual 100
}
test("removeOldSnapshot") {
val versionBuilder = VersionBuilderLocal()
val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
val repo = getSnapshotRepoTest(eventStore, SnapshotConfig(2))
val aggregateId = IdTest()
fun buildEndSendEventX() {
EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
.also { eventStore.publish(it) } .also { eventStore.publish(it) }
.also { repo.applyAndPutToCache(it) } }
repo.applyAndPutToCache(eventX)
} }
}
}.joinAll()
assertNotNull(repo.getLast(aggregateId)).num shouldBeEqual 100
}
buildEndSendEventX() test("removeOldSnapshot") {
repo.getLast(aggregateId).num shouldBeEqual 1 val versionBuilder = VersionBuilderLocal()
buildEndSendEventX() val eventStore: EventStore<TestEvents, IdTest> = EventStoreInMemory()
repo.getLast(aggregateId).num shouldBeEqual 2 val repo = getSnapshotRepoTest(eventStore, SnapshotConfig(2))
buildEndSendEventX() val aggregateId = IdTest()
repo.getLast(aggregateId).num shouldBeEqual 3
buildEndSendEventX() fun buildEndSendEventX() {
repo.getLast(aggregateId).num shouldBeEqual 4 EventXTest(num = 1, version = versionBuilder.buildNextVersion(aggregateId), aggregateId = aggregateId)
} .also { eventStore.publish(it) }
}) .also { repo.applyAndPutToCache(it) }
}
buildEndSendEventX()
repo.getLast(aggregateId).num shouldBeEqual 1
buildEndSendEventX()
repo.getLast(aggregateId).num shouldBeEqual 2
buildEndSendEventX()
repo.getLast(aggregateId).num shouldBeEqual 3
buildEndSendEventX()
repo.getLast(aggregateId).num shouldBeEqual 4
}
})
@JvmInline @JvmInline
private value class IdTest( private value class IdTest(
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId ) : AggregateId
private data class ProjectionTest( private data class ProjectionTest(
override val aggregateId: IdTest, override val aggregateId: IdTest,
override val lastEventVersion: Int = 0, override val lastEventVersion: Int = 0,
var value: String? = null, var value: String? = null,
var num: Int = 0, var num: Int = 0,
) : Projection<IdTest> ) : Projection<IdTest>
private sealed interface TestEvents : Event<IdTest> private sealed interface TestEvents : Event<IdTest>
private data class Event1Test( private data class Event1Test(
override val eventId: UUID = UUID.randomUUID(), override val eventId: UUID = UUID.randomUUID(),
override val aggregateId: IdTest, override val aggregateId: IdTest,
override val createdAt: Instant = Clock.System.now(), override val createdAt: Instant = Clock.System.now(),
override val version: Int, override val version: Int,
val value1: String, val value1: String,
) : TestEvents ) : TestEvents
private data class Event2Test( private data class Event2Test(
override val eventId: UUID = UUID.randomUUID(), override val eventId: UUID = UUID.randomUUID(),
override val aggregateId: IdTest, override val aggregateId: IdTest,
override val createdAt: Instant = Clock.System.now(), override val createdAt: Instant = Clock.System.now(),
override val version: Int, override val version: Int,
val value2: String, val value2: String,
) : TestEvents ) : TestEvents
private data class EventXTest( private data class EventXTest(
override val eventId: UUID = UUID.randomUUID(), override val eventId: UUID = UUID.randomUUID(),
override val aggregateId: IdTest, override val aggregateId: IdTest,
override val createdAt: Instant = Clock.System.now(), override val createdAt: Instant = Clock.System.now(),
override val version: Int, override val version: Int,
val num: Int, val num: Int,
) : TestEvents ) : TestEvents
private fun getSnapshotRepoTest( private fun getSnapshotRepoTest(
eventStore: EventStore<TestEvents, IdTest>, eventStore: EventStore<TestEvents, IdTest>,
snapshotConfig: SnapshotConfig = SnapshotConfig(2000), snapshotConfig: SnapshotConfig = SnapshotConfig(2000),
): ProjectionSnapshotRepositoryInMemory<TestEvents, ProjectionTest, IdTest> = ): ProjectionSnapshotRepositoryInMemory<TestEvents, ProjectionTest, IdTest> =
ProjectionSnapshotRepositoryInMemory( ProjectionSnapshotRepositoryInMemory(
eventStore = eventStore, eventStore = eventStore,
initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) }, initialStateBuilder = { aggregateId: IdTest -> ProjectionTest(aggregateId) },
snapshotCacheConfig = snapshotConfig, snapshotCacheConfig = snapshotConfig,
) { event -> ) { event ->
this.let { projection -> this.let { projection ->
when (event) { when (event) {
is Event1Test -> { is Event1Test -> {
projection.copy(value = (projection.value ?: "") + event.value1) projection.copy(value = (projection.value ?: "") + event.value1)
}
is Event2Test -> {
projection.copy(value = (projection.value ?: "") + event.value2)
}
is EventXTest -> {
projection.copy(num = projection.num + event.num)
}
}.copy(
lastEventVersion = event.version,
)
} }
is Event2Test -> {
projection.copy(value = (projection.value ?: "") + event.value2)
}
is EventXTest -> {
projection.copy(num = projection.num + event.num)
}
}.copy(
lastEventVersion = event.version,
)
} }
}

View File

@@ -33,89 +33,89 @@ import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
class GameStateRouteTest : class GameStateRouteTest :
FunSpec({ FunSpec({
test("/game/{id}/state on empty game") { test("/game/{id}/state on empty game") {
testApplication { testApplication {
val id = GameId() val id = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
application { application {
stopKoin() stopKoin()
configure() configure()
}
httpClient()
.get("/game/$id/state") {
withAuth(player1)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val state = call.body<GameState>()
id shouldBeEqual state.aggregateId
state.players shouldHaveSize 0
state.isStarted shouldBeEqual false
}
}
} }
test("/game/{id}/card/last") { httpClient()
testApplication { .get("/game/$id/state") {
val gameId = GameId() withAuth(player1)
val player1 = Player(name = "Nikola") accept(ContentType.Application.Json)
val player2 = Player(name = "Einstein") }.apply {
var lastPlayedCard: Card? = null assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val state = call.body<GameState>()
id shouldBeEqual state.aggregateId
state.players shouldHaveSize 0
state.isStarted shouldBeEqual false
}
}
}
application { test("/game/{id}/card/last") {
stopKoin() testApplication {
configure() val gameId = GameId()
val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein")
var lastPlayedCard: Card? = null
val eventHandler by inject<GameEventHandler>() application {
val stateRepo by inject<GameStateRepository>() stopKoin()
runBlocking { configure()
eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) }
eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) }
eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) }
eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) }
eventHandler.handle(gameId) {
GameStartedEvent.new(
gameId,
setOf(player1, player2),
shuffleIsDisabled = true,
it,
)
}
delay(100)
lastPlayedCard = stateRepo.getLast(gameId).playableCards(player1).first()
assertNotNull(lastPlayedCard)
.let { assertIs<Card.NumericCard>(lastPlayedCard) }
.let {
it.number shouldBeEqual 0
it.color shouldBeEqual Card.Color.Red
}
delay(100)
eventHandler.handle(gameId) {
CardIsPlayedEvent(
gameId,
assertNotNull(lastPlayedCard),
player1,
it,
)
}
delay(100)
}
}
httpClient() val eventHandler by inject<GameEventHandler>()
.get("/game/$gameId/card/last") { val stateRepo by inject<GameStateRepository>()
withAuth(player1) runBlocking {
accept(ContentType.Application.Json) eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) }
}.apply { eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) }
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) }
assertEquals(assertNotNull(lastPlayedCard), call.body<Card>()) eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) }
} eventHandler.handle(gameId) {
GameStartedEvent.new(
gameId,
setOf(player1, player2),
shuffleIsDisabled = true,
it,
)
} }
delay(100)
lastPlayedCard = stateRepo.getLast(gameId).playableCards(player1).first()
assertNotNull(lastPlayedCard)
.let { assertIs<Card.NumericCard>(lastPlayedCard) }
.let {
it.number shouldBeEqual 0
it.color shouldBeEqual Card.Color.Red
}
delay(100)
eventHandler.handle(gameId) {
CardIsPlayedEvent(
gameId,
assertNotNull(lastPlayedCard),
player1,
it,
)
}
delay(100)
}
} }
})
httpClient()
.get("/game/$gameId/card/last") {
withAuth(player1)
accept(ContentType.Application.Json)
}.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
assertEquals(assertNotNull(lastPlayedCard), call.body<Card>())
}
}
}
})
private fun HttpRequestBuilder.withAuth(player: Player) { private fun HttpRequestBuilder.withAuth(player: Player) {
header("Authorization", "Bearer ${player.makeJwt()}") header("Authorization", "Bearer ${player.makeJwt()}")
} }

View File

@@ -42,127 +42,127 @@ import kotlin.time.Duration.Companion.seconds
@DelicateCoroutinesApi @DelicateCoroutinesApi
class GameStateTest : class GameStateTest :
FunSpec({ FunSpec({
test("Simulation of a game") { test("Simulation of a game") {
withTimeout(2.seconds) { withTimeout(2.seconds) {
disableShuffleDeck() disableShuffleDeck()
val id = GameId() val id = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein") val player2 = Player(name = "Einstein")
val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED) val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED)
val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED) val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification1 = Channel<Notification>(Channel.BUFFERED) val channelNotification1 = Channel<Notification>(Channel.BUFFERED)
val channelNotification2 = Channel<Notification>(Channel.BUFFERED) val channelNotification2 = Channel<Notification>(Channel.BUFFERED)
var playedCard1: Card? = null var playedCard1: Card? = null
var playedCard2: Card? = null var playedCard2: Card? = null
val player1Job = val player1Job =
launch { launch {
channelCommand1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1))) channelCommand1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1)))
channelNotification1.receive().let { channelNotification1.receive().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1) assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1)
}
channelNotification1.receive().let {
assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2
}
channelCommand1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)))
channelNotification1.receive().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2
}
val player1Hand =
channelNotification1.receive().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
}
playedCard1 = player1Hand.first()
channelNotification1.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player1
}
}
channelCommand1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())))
channelNotification1.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player2
}
}
channelNotification1.receive().let {
assertIs<PlayerAsPlayACardNotification>(it).apply {
player shouldBeEqual player2
card shouldBeEqual assertNotNull(playedCard2)
}
}
}
val player2Job =
launch {
delay(100)
channelCommand2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)))
channelNotification2.receive().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2)
}
channelNotification2.receive().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1
}
channelCommand2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)))
val player2Hand =
channelNotification2.receive().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
}
channelNotification2.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player1
}
}
channelNotification2.receive().let {
assertIs<PlayerAsPlayACardNotification>(it).apply {
player shouldBeEqual player1
card shouldBeEqual assertNotNull(playedCard1)
}
}
playedCard2 = player2Hand.first()
channelNotification2.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player2
}
}
channelCommand2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())))
}
koinApplication { modules(appKoinModule) }.koin.apply {
val commandHandler by inject<GameCommandHandler>()
val eventStore by inject<GameEventStore>()
val playerNotificationListener by inject<PlayerNotificationEventListener>()
ReactionEventListener(get(), get(), get()).init()
playerNotificationListener.startListening(channelNotification1, player1)
playerNotificationListener.startListening(channelNotification2, player2)
GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player1, channelCommand1, channelNotification1)
}
GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player2, channelCommand2, channelNotification2)
}
joinAll(player1Job, player2Job)
val state =
ProjectionSnapshotRepositoryInMemory(
eventStore = eventStore,
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
applyToProjection = GameState::apply,
).getLast(id)
state.aggregateId 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.cardOnCurrentStack) shouldBeEqual GameState.LastCard(assertNotNull(playedCard2), player2)
}
} }
channelNotification1.receive().let {
assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2
}
channelCommand1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)))
channelNotification1.receive().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2
}
val player1Hand =
channelNotification1.receive().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
}
playedCard1 = player1Hand.first()
channelNotification1.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player1
}
}
channelCommand1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())))
channelNotification1.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player2
}
}
channelNotification1.receive().let {
assertIs<PlayerAsPlayACardNotification>(it).apply {
player shouldBeEqual player2
card shouldBeEqual assertNotNull(playedCard2)
}
}
}
val player2Job =
launch {
delay(100)
channelCommand2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)))
channelNotification2.receive().let {
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2)
}
channelNotification2.receive().let {
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1
}
channelCommand2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)))
val player2Hand =
channelNotification2.receive().let {
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
}
channelNotification2.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player1
}
}
channelNotification2.receive().let {
assertIs<PlayerAsPlayACardNotification>(it).apply {
player shouldBeEqual player1
card shouldBeEqual assertNotNull(playedCard1)
}
}
playedCard2 = player2Hand.first()
channelNotification2.receive().let {
assertIs<ItsTheTurnOfNotification>(it).apply {
player shouldBeEqual player2
}
}
channelCommand2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())))
}
koinApplication { modules(appKoinModule) }.koin.apply {
val commandHandler by inject<GameCommandHandler>()
val eventStore by inject<GameEventStore>()
val playerNotificationListener by inject<PlayerNotificationEventListener>()
ReactionEventListener(get(), get(), get()).init()
playerNotificationListener.startListening(channelNotification1, player1)
playerNotificationListener.startListening(channelNotification2, player2)
GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player1, channelCommand1, channelNotification1)
}
GlobalScope.launch(Dispatchers.IO) {
commandHandler.handle(player2, channelCommand2, channelNotification2)
}
joinAll(player1Job, player2Job)
val state =
ProjectionSnapshotRepositoryInMemory(
eventStore = eventStore,
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
applyToProjection = GameState::apply,
).getLast(id)
state.aggregateId 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.cardOnCurrentStack) shouldBeEqual GameState.LastCard(assertNotNull(playedCard2), player2)
} }
}) }
}
})

View File

@@ -6,10 +6,10 @@ import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.ApplicationTestBuilder
fun ApplicationTestBuilder.httpClient(): HttpClient = fun ApplicationTestBuilder.httpClient(): HttpClient =
createClient { createClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json( json(
defaultJsonSerializer(), defaultJsonSerializer(),
) )
}
} }
}

View File

@@ -14,42 +14,42 @@ import kotlin.test.assertIs
@Serializable @Serializable
data class CommandTest( data class CommandTest(
override val id: CommandId, override val id: CommandId,
) : Command ) : Command
class FrameChannelConverterTest : class FrameChannelConverterTest :
FunSpec({ FunSpec({
test("toObjectChannel") { test("toObjectChannel") {
val uuid = "d737c631-76af-406e-bc29-f3e5b97226a5" val uuid = "d737c631-76af-406e-bc29-f3e5b97226a5"
val id = CommandId(UUID.fromString(uuid)) val id = CommandId(UUID.fromString(uuid))
val jsonCommand = """{"id":"$uuid"}""" val jsonCommand = """{"id":"$uuid"}"""
val channel = Channel<Frame>() val channel = Channel<Frame>()
launch { launch {
val commandChannel = toObjectChannel<CommandTest>(channel) val commandChannel = toObjectChannel<CommandTest>(channel)
commandChannel.receive().id shouldBeEqual id commandChannel.receive().id shouldBeEqual id
channel.close() channel.close()
} }
channel.send(Frame.Text(jsonCommand)) channel.send(Frame.Text(jsonCommand))
} }
test("fromFrameChannel") { test("fromFrameChannel") {
val uuid = "d737c631-76af-406e-bc29-f3e5b97226a5" val uuid = "d737c631-76af-406e-bc29-f3e5b97226a5"
val id = CommandId(UUID.fromString(uuid)) val id = CommandId(UUID.fromString(uuid))
val command = CommandTest(id) val command = CommandTest(id)
val jsonCommand = """{"id":"$uuid"}""" val jsonCommand = """{"id":"$uuid"}"""
val channel = Channel<Frame>() val channel = Channel<Frame>()
launch { launch {
val commandChannel = fromFrameChannel<CommandTest>(channel) val commandChannel = fromFrameChannel<CommandTest>(channel)
commandChannel.send(command) commandChannel.send(command)
commandChannel.close() commandChannel.close()
} }
assertIs<Frame.Text>(channel.receive()).readText() shouldBeEqual jsonCommand assertIs<Frame.Text>(channel.receive()).readText() shouldBeEqual jsonCommand
} }
}) })

View File

@@ -8,26 +8,26 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
class CommandTest( class CommandTest(
override val id: CommandId, override val id: CommandId,
) : Command ) : Command
class CommandStreamChannelTest : class CommandStreamChannelTest :
FunSpec({ FunSpec({
test("send and receive") { test("send and receive") {
val command = CommandTest(CommandId()) val command = CommandTest(CommandId())
val channel = Channel<CommandTest>() val channel = Channel<CommandTest>()
val stream = val stream =
CommandStreamChannel(channel) CommandStreamChannel(channel)
val spyCall: () -> Unit = mockk(relaxed = true) val spyCall: () -> Unit = mockk(relaxed = true)
stream.blockAndProcess { stream.blockAndProcess {
println("In action ${it.id}") println("In action ${it.id}")
spyCall() spyCall()
} }
channel.send(command) channel.send(command)
verify(exactly = 1) { spyCall() } verify(exactly = 1) { spyCall() }
} }
}) })

View File

@@ -3,8 +3,8 @@ package eventDemo.libs.event
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class EventBusInMemoryTest : class EventBusInMemoryTest :
FunSpec({ FunSpec({
xtest("publish should call the subscribed functions") { } xtest("publish should call the subscribed functions") { }
xtest("publish should call the subscribed functions on the priority order") { } xtest("publish should call the subscribed functions on the priority order") { }
}) })

View File

@@ -3,14 +3,14 @@ package eventDemo.libs.event
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
class EventStreamInMemoryTest : class EventStreamInMemoryTest :
FunSpec({ FunSpec({
xtest("publish should be concurrently secure") { } xtest("publish should be concurrently secure") { }
xtest("readLast should only return the event of aggregate") { } xtest("readLast should only return the event of aggregate") { }
xtest("readLast should return the last event of the aggregate") { } xtest("readLast should return the last event of the aggregate") { }
xtest("readLastOf should return the last event of the aggregate of the type") { } xtest("readLastOf should return the last event of the aggregate of the type") { }
xtest("readAll should only return the event of aggregate") { } xtest("readAll should only return the event of aggregate") { }
}) })

View File

@@ -10,43 +10,43 @@ import java.util.UUID
@JvmInline @JvmInline
private value class IdTest( private value class IdTest(
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId ) : AggregateId
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class VersionBuilderLocalTest : class VersionBuilderLocalTest :
FunSpec({ FunSpec({
test("buildNextVersion") { test("buildNextVersion") {
VersionBuilderLocal().run { VersionBuilderLocal().run {
val id = IdTest() val id = IdTest()
buildNextVersion(id) shouldBeEqual 1 buildNextVersion(id) shouldBeEqual 1
buildNextVersion(id) shouldBeEqual 2 buildNextVersion(id) shouldBeEqual 2
buildNextVersion(IdTest()) shouldBeEqual 1 buildNextVersion(IdTest()) shouldBeEqual 1
buildNextVersion(id) shouldBeEqual 3 buildNextVersion(id) shouldBeEqual 3
}
}
test("buildNextVersion concurrently") {
val versionBuilder = VersionBuilderLocal()
val id = IdTest()
(1..20)
.map {
GlobalScope.launch {
(1..1000).map {
versionBuilder.buildNextVersion(id)
} }
} }
}.joinAll()
versionBuilder.getLastVersion(id) shouldBeEqual 20 * 1000
}
test("buildNextVersion concurrently") { test("getLastVersion") {
val versionBuilder = VersionBuilderLocal() VersionBuilderLocal().run {
val id = IdTest() val id = IdTest()
(1..20) getLastVersion(id) shouldBeEqual 0
.map { getLastVersion(id) shouldBeEqual 0
GlobalScope.launch { getLastVersion(id) shouldBeEqual 0
(1..1000).map { }
versionBuilder.buildNextVersion(id) }
} })
}
}.joinAll()
versionBuilder.getLastVersion(id) shouldBeEqual 20 * 1000
}
test("getLastVersion") {
VersionBuilderLocal().run {
val id = IdTest()
getLastVersion(id) shouldBeEqual 0
getLastVersion(id) shouldBeEqual 0
getLastVersion(id) shouldBeEqual 0
}
}
})