Send Success notification when Command is executed
extract Action of the Commands simplify somme classes
This commit is contained in:
@@ -1,29 +0,0 @@
|
|||||||
package eventDemo.app.command
|
|
||||||
|
|
||||||
import eventDemo.app.command.command.GameCommand
|
|
||||||
import eventDemo.app.notification.ErrorNotification
|
|
||||||
import eventDemo.app.notification.Notification
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
|
|
||||||
typealias ErrorNotifier = suspend (String) -> Unit
|
|
||||||
|
|
||||||
fun errorNotifier(
|
|
||||||
command: GameCommand,
|
|
||||||
channel: SendChannel<Notification>,
|
|
||||||
): ErrorNotifier =
|
|
||||||
{
|
|
||||||
val logger = KotlinLogging.logger { }
|
|
||||||
ErrorNotification(message = it, command = command)
|
|
||||||
.let { notification ->
|
|
||||||
logger.atWarn {
|
|
||||||
message = "Notification ERROR sent: ${notification.message}"
|
|
||||||
payload =
|
|
||||||
mapOf(
|
|
||||||
"notification" to notification,
|
|
||||||
"command" to command,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
channel.send(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,27 @@
|
|||||||
package eventDemo.app.command
|
package eventDemo.app.command
|
||||||
|
|
||||||
|
import eventDemo.app.command.action.ICantPlay
|
||||||
|
import eventDemo.app.command.action.IWantToJoinTheGame
|
||||||
|
import eventDemo.app.command.action.IWantToPlayCard
|
||||||
|
import eventDemo.app.command.action.IamReadyToPlay
|
||||||
import eventDemo.app.command.command.GameCommand
|
import eventDemo.app.command.command.GameCommand
|
||||||
import eventDemo.app.command.command.ICantPlayCommand
|
import eventDemo.app.command.command.ICantPlayCommand
|
||||||
import eventDemo.app.command.command.IWantToJoinTheGameCommand
|
import eventDemo.app.command.command.IWantToJoinTheGameCommand
|
||||||
import eventDemo.app.command.command.IWantToPlayCardCommand
|
import eventDemo.app.command.command.IWantToPlayCardCommand
|
||||||
import eventDemo.app.command.command.IamReadyToPlayCommand
|
import eventDemo.app.command.command.IamReadyToPlayCommand
|
||||||
import eventDemo.app.event.GameEventHandler
|
import eventDemo.app.event.event.GameEvent
|
||||||
import eventDemo.app.event.projection.GameStateRepository
|
|
||||||
import eventDemo.app.notification.Notification
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
|
|
||||||
class GameCommandActionRunner(
|
class GameCommandActionRunner(
|
||||||
private val eventHandler: GameEventHandler,
|
private val iWantToPlayCard: IWantToPlayCard,
|
||||||
private val gameStateRepository: GameStateRepository,
|
private val iamReadyToPlay: IamReadyToPlay,
|
||||||
|
private val iWantToJoinTheGame: IWantToJoinTheGame,
|
||||||
|
private val iCantPlay: ICantPlay,
|
||||||
) {
|
) {
|
||||||
suspend fun run(
|
fun run(command: GameCommand): (Int) -> GameEvent =
|
||||||
command: GameCommand,
|
when (command) {
|
||||||
outgoingErrorChannelNotification: SendChannel<Notification>,
|
is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
|
||||||
) {
|
is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
|
||||||
val gameState = gameStateRepository.getLast(command.payload.aggregateId)
|
is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
|
||||||
|
is ICantPlayCommand -> iCantPlay.run(command)
|
||||||
try {
|
|
||||||
when (command) {
|
|
||||||
is IWantToPlayCardCommand -> command.run(gameState, this.eventHandler)
|
|
||||||
is IamReadyToPlayCommand -> command.run(gameState, this.eventHandler)
|
|
||||||
is IWantToJoinTheGameCommand -> command.run(gameState, this.eventHandler)
|
|
||||||
is ICantPlayCommand -> command.run(gameState, this.eventHandler)
|
|
||||||
}
|
|
||||||
} catch (e: CommandException) {
|
|
||||||
errorNotifier(command, outgoingErrorChannelNotification)(e.message)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,46 +2,144 @@ package eventDemo.app.command
|
|||||||
|
|
||||||
import eventDemo.app.command.command.GameCommand
|
import eventDemo.app.command.command.GameCommand
|
||||||
import eventDemo.app.entity.Player
|
import eventDemo.app.entity.Player
|
||||||
|
import eventDemo.app.event.GameEventBus
|
||||||
|
import eventDemo.app.event.GameEventHandler
|
||||||
import eventDemo.app.event.event.GameEvent
|
import eventDemo.app.event.event.GameEvent
|
||||||
|
import eventDemo.app.notification.CommandErrorNotification
|
||||||
|
import eventDemo.app.notification.CommandSuccessNotification
|
||||||
import eventDemo.app.notification.Notification
|
import eventDemo.app.notification.Notification
|
||||||
import eventDemo.libs.command.CommandStreamChannelBuilder
|
import eventDemo.libs.command.CommandId
|
||||||
|
import eventDemo.libs.command.CommandStreamChannel
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen [GameCommand] on [GameCommandStream], check the validity and execute an action.
|
* Listen [GameCommand] on [CommandStreamChannel], check the validity and execute an action.
|
||||||
*
|
*
|
||||||
* 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: CommandStreamChannel<GameCommand>,
|
||||||
|
private val eventHandler: GameEventHandler,
|
||||||
private val runner: GameCommandActionRunner,
|
private val runner: GameCommandActionRunner,
|
||||||
|
eventBus: GameEventBus,
|
||||||
|
listenerPriority: Int = DEFAULT_PRIORITY,
|
||||||
) {
|
) {
|
||||||
private val logger = KotlinLogging.logger { }
|
private val logger = KotlinLogging.logger { }
|
||||||
|
private val eventCommandMap = EventCommandMap()
|
||||||
|
|
||||||
|
companion object Config {
|
||||||
|
const val DEFAULT_PRIORITY = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe to the event bus to send success notification after save the event.
|
||||||
|
init {
|
||||||
|
eventBus.subscribe(listenerPriority) { event: GameEvent ->
|
||||||
|
eventCommandMap[event.eventId]?.apply {
|
||||||
|
channel.sendSuccess(commandId)()
|
||||||
|
} ?: logger.warn { "No Notification for event: $event" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Init the handler
|
* Run a command and publish the event.
|
||||||
|
*
|
||||||
|
* It restricts to run only once a command.
|
||||||
|
*
|
||||||
|
* If the command fail, send an [error notification][CommandErrorNotification],
|
||||||
|
* if success, send a [success notification][CommandSuccessNotification]
|
||||||
*/
|
*/
|
||||||
suspend fun handle(
|
suspend fun handle(
|
||||||
player: Player,
|
player: Player,
|
||||||
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
||||||
outgoingErrorChannelNotification: SendChannel<Notification>,
|
channelNotification: SendChannel<Notification>,
|
||||||
) =
|
) =
|
||||||
commandStreamChannel(incomingCommandChannel)
|
commandStreamChannel.process(incomingCommandChannel) { command ->
|
||||||
.process { command ->
|
if (command.payload.player.id != player.id) {
|
||||||
if (command.payload.player.id != player.id) {
|
logger.atWarn {
|
||||||
|
message = "Handle command Refuse, the player of the command is not the same: $command"
|
||||||
|
payload = mapOf("command" to command)
|
||||||
|
}
|
||||||
|
channelNotification.sendError(command)("You are not the author of this command\n")
|
||||||
|
} else {
|
||||||
|
logger.atInfo {
|
||||||
|
message = "Handle command: $command"
|
||||||
|
payload = mapOf("command" to command)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val eventBuilder = runner.run(command)
|
||||||
|
|
||||||
|
eventHandler.handle(command.payload.aggregateId) { version ->
|
||||||
|
eventBuilder(version)
|
||||||
|
.also { eventCommandMap.set(it.eventId, channelNotification, command.id) }
|
||||||
|
}
|
||||||
|
} catch (e: CommandException) {
|
||||||
logger.atWarn {
|
logger.atWarn {
|
||||||
message = "Handle command Refuse, the player of the command is not the same: $command"
|
message = e.message
|
||||||
payload = mapOf("command" to command)
|
payload = mapOf("command" to command)
|
||||||
}
|
}
|
||||||
nack()
|
channelNotification.sendError(command)(e.message)
|
||||||
} else {
|
|
||||||
logger.atInfo {
|
|
||||||
message = "Handle command: $command"
|
|
||||||
payload = mapOf("command" to command)
|
|
||||||
}
|
|
||||||
runner.run(command, outgoingErrorChannelNotification)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SendChannel<Notification>.sendSuccess(commandId: CommandId): suspend () -> Unit =
|
||||||
|
{
|
||||||
|
val logger = KotlinLogging.logger { }
|
||||||
|
CommandSuccessNotification(commandId = commandId)
|
||||||
|
.also { notification ->
|
||||||
|
logger.atDebug {
|
||||||
|
message = "Notification SUCCESS sent"
|
||||||
|
payload =
|
||||||
|
mapOf(
|
||||||
|
"notification" to notification,
|
||||||
|
"commandId" to commandId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
send(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SendChannel<Notification>.sendError(command: GameCommand): suspend (String) -> Unit =
|
||||||
|
{
|
||||||
|
val logger = KotlinLogging.logger { }
|
||||||
|
CommandErrorNotification(message = it, command = command)
|
||||||
|
.also { notification ->
|
||||||
|
logger.atWarn {
|
||||||
|
message = "Notification ERROR sent: ${notification.message}"
|
||||||
|
payload =
|
||||||
|
mapOf(
|
||||||
|
"notification" to notification,
|
||||||
|
"command" to command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
send(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map to record the command that triggered the event.
|
||||||
|
*/
|
||||||
|
private class EventCommandMap {
|
||||||
|
val map = ConcurrentHashMap<UUID, Output>()
|
||||||
|
|
||||||
|
fun set(
|
||||||
|
eventId: UUID,
|
||||||
|
channel: SendChannel<Notification>,
|
||||||
|
commandId: CommandId,
|
||||||
|
) {
|
||||||
|
map[eventId] = Output(channel, commandId)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(eventId: UUID): Output? =
|
||||||
|
map[eventId]
|
||||||
|
|
||||||
|
data class Output(
|
||||||
|
val channel: SendChannel<Notification>,
|
||||||
|
val commandId: CommandId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package eventDemo.app.command
|
|
||||||
|
|
||||||
import eventDemo.app.command.command.GameCommand
|
|
||||||
import eventDemo.libs.command.CommandStream
|
|
||||||
import eventDemo.libs.command.CommandStreamChannel
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream to publish and read the game command.
|
|
||||||
*/
|
|
||||||
class GameCommandStream(
|
|
||||||
incoming: ReceiveChannel<GameCommand>,
|
|
||||||
) : CommandStream<GameCommand> by CommandStreamChannel(incoming)
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package eventDemo.app.command.action
|
||||||
|
|
||||||
|
import eventDemo.libs.command.Command
|
||||||
|
import eventDemo.libs.event.Event
|
||||||
|
|
||||||
|
sealed interface CommandAction<C : Command, E : Event<*>> {
|
||||||
|
fun run(command: C): (Int) -> E
|
||||||
|
}
|
||||||
36
src/main/kotlin/eventDemo/app/command/action/ICantPlay.kt
Normal file
36
src/main/kotlin/eventDemo/app/command/action/ICantPlay.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package eventDemo.app.command.action
|
||||||
|
|
||||||
|
import eventDemo.app.command.CommandException
|
||||||
|
import eventDemo.app.command.command.ICantPlayCommand
|
||||||
|
import eventDemo.app.event.event.PlayerHavePassEvent
|
||||||
|
import eventDemo.app.event.projection.GameStateRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
data class ICantPlay(
|
||||||
|
private val gameStateRepository: GameStateRepository,
|
||||||
|
) : CommandAction<ICantPlayCommand, PlayerHavePassEvent> {
|
||||||
|
override fun run(command: ICantPlayCommand): (Int) -> PlayerHavePassEvent {
|
||||||
|
val state = gameStateRepository.getLast(command.payload.aggregateId)
|
||||||
|
|
||||||
|
if (state.currentPlayerTurn != command.payload.player) {
|
||||||
|
throw CommandException("Its not your turn!")
|
||||||
|
}
|
||||||
|
|
||||||
|
val playableCards = state.playableCards(command.payload.player)
|
||||||
|
if (playableCards.isNotEmpty()) {
|
||||||
|
throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val takenCard = state.deck.stack.first()
|
||||||
|
return { version ->
|
||||||
|
PlayerHavePassEvent(
|
||||||
|
aggregateId = command.payload.aggregateId,
|
||||||
|
player = command.payload.player,
|
||||||
|
takenCard = takenCard,
|
||||||
|
version = version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package eventDemo.app.command.action
|
||||||
|
|
||||||
|
import eventDemo.app.command.CommandException
|
||||||
|
import eventDemo.app.command.command.IWantToJoinTheGameCommand
|
||||||
|
import eventDemo.app.event.event.NewPlayerEvent
|
||||||
|
import eventDemo.app.event.projection.GameStateRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
data class IWantToJoinTheGame(
|
||||||
|
private val gameStateRepository: GameStateRepository,
|
||||||
|
) : CommandAction<IWantToJoinTheGameCommand, NewPlayerEvent> {
|
||||||
|
override fun run(command: IWantToJoinTheGameCommand): (Int) -> NewPlayerEvent {
|
||||||
|
val state = gameStateRepository.getLast(command.payload.aggregateId)
|
||||||
|
if (!state.isStarted) {
|
||||||
|
return {
|
||||||
|
NewPlayerEvent(
|
||||||
|
aggregateId = command.payload.aggregateId,
|
||||||
|
player = command.payload.player,
|
||||||
|
version = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw CommandException("The game is already started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eventDemo.app.command.action
|
||||||
|
|
||||||
|
import eventDemo.app.command.CommandException
|
||||||
|
import eventDemo.app.command.command.IWantToPlayCardCommand
|
||||||
|
import eventDemo.app.event.event.CardIsPlayedEvent
|
||||||
|
import eventDemo.app.event.projection.GameStateRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
data class IWantToPlayCard(
|
||||||
|
private val gameStateRepository: GameStateRepository,
|
||||||
|
) : CommandAction<IWantToPlayCardCommand, CardIsPlayedEvent> {
|
||||||
|
override fun run(command: IWantToPlayCardCommand): (Int) -> CardIsPlayedEvent {
|
||||||
|
val state = gameStateRepository.getLast(command.payload.aggregateId)
|
||||||
|
|
||||||
|
if (!state.isStarted) {
|
||||||
|
throw CommandException("The game is Not started")
|
||||||
|
}
|
||||||
|
if (state.currentPlayerTurn != command.payload.player) {
|
||||||
|
throw CommandException("Its not your turn!")
|
||||||
|
}
|
||||||
|
if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) {
|
||||||
|
throw CommandException("You cannot play this card")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version ->
|
||||||
|
CardIsPlayedEvent(
|
||||||
|
aggregateId = command.payload.aggregateId,
|
||||||
|
card = command.payload.card,
|
||||||
|
player = command.payload.player,
|
||||||
|
version = version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eventDemo.app.command.action
|
||||||
|
|
||||||
|
import eventDemo.app.command.CommandException
|
||||||
|
import eventDemo.app.command.command.IamReadyToPlayCommand
|
||||||
|
import eventDemo.app.event.event.PlayerReadyEvent
|
||||||
|
import eventDemo.app.event.projection.GameStateRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to set as ready to play
|
||||||
|
*/
|
||||||
|
class IamReadyToPlay(
|
||||||
|
private val gameStateRepository: GameStateRepository,
|
||||||
|
) : CommandAction<IamReadyToPlayCommand, PlayerReadyEvent> {
|
||||||
|
@Throws(CommandException::class)
|
||||||
|
override fun run(command: IamReadyToPlayCommand): (Int) -> PlayerReadyEvent {
|
||||||
|
val state = gameStateRepository.getLast(command.payload.aggregateId)
|
||||||
|
val playerExist: Boolean = state.players.contains(command.payload.player)
|
||||||
|
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player)
|
||||||
|
|
||||||
|
if (state.isStarted) {
|
||||||
|
throw CommandException("The game is already started")
|
||||||
|
} else if (!playerExist) {
|
||||||
|
throw CommandException("You are not in the game")
|
||||||
|
} else if (playerIsAlreadyReady) {
|
||||||
|
throw CommandException("You are already ready")
|
||||||
|
} else {
|
||||||
|
return { version: Int ->
|
||||||
|
PlayerReadyEvent(
|
||||||
|
aggregateId = command.payload.aggregateId,
|
||||||
|
player = command.payload.player,
|
||||||
|
version = version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
package eventDemo.app.command.command
|
package eventDemo.app.command.command
|
||||||
|
|
||||||
import eventDemo.app.command.CommandException
|
|
||||||
import eventDemo.app.entity.GameId
|
import eventDemo.app.entity.GameId
|
||||||
import eventDemo.app.entity.Player
|
import eventDemo.app.entity.Player
|
||||||
import eventDemo.app.event.GameEventHandler
|
|
||||||
import eventDemo.app.event.event.PlayerHavePassEvent
|
|
||||||
import eventDemo.app.event.projection.GameState
|
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -23,28 +19,4 @@ data class ICantPlayCommand(
|
|||||||
override val aggregateId: GameId,
|
override val aggregateId: GameId,
|
||||||
override val player: Player,
|
override val player: Player,
|
||||||
) : GameCommand.Payload
|
) : GameCommand.Payload
|
||||||
|
|
||||||
suspend fun run(
|
|
||||||
state: GameState,
|
|
||||||
eventHandler: GameEventHandler,
|
|
||||||
) {
|
|
||||||
if (state.currentPlayerTurn != payload.player) {
|
|
||||||
throw CommandException("Its not your turn!")
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package eventDemo.app.command.command
|
package eventDemo.app.command.command
|
||||||
|
|
||||||
import eventDemo.app.command.CommandException
|
|
||||||
import eventDemo.app.entity.GameId
|
import eventDemo.app.entity.GameId
|
||||||
import eventDemo.app.entity.Player
|
import eventDemo.app.entity.Player
|
||||||
import eventDemo.app.event.GameEventHandler
|
|
||||||
import eventDemo.app.event.event.NewPlayerEvent
|
|
||||||
import eventDemo.app.event.projection.GameState
|
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -23,21 +19,4 @@ data class IWantToJoinTheGameCommand(
|
|||||||
override val aggregateId: GameId,
|
override val aggregateId: GameId,
|
||||||
override val player: Player,
|
override val player: Player,
|
||||||
) : GameCommand.Payload
|
) : GameCommand.Payload
|
||||||
|
|
||||||
suspend fun run(
|
|
||||||
state: GameState,
|
|
||||||
eventHandler: GameEventHandler,
|
|
||||||
) {
|
|
||||||
if (!state.isStarted) {
|
|
||||||
eventHandler.handle(payload.aggregateId) {
|
|
||||||
NewPlayerEvent(
|
|
||||||
aggregateId = payload.aggregateId,
|
|
||||||
player = payload.player,
|
|
||||||
version = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw CommandException("The game is already started")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package eventDemo.app.command.command
|
package eventDemo.app.command.command
|
||||||
|
|
||||||
import eventDemo.app.command.CommandException
|
|
||||||
import eventDemo.app.entity.Card
|
import eventDemo.app.entity.Card
|
||||||
import eventDemo.app.entity.GameId
|
import eventDemo.app.entity.GameId
|
||||||
import eventDemo.app.entity.Player
|
import eventDemo.app.entity.Player
|
||||||
import eventDemo.app.event.GameEventHandler
|
|
||||||
import eventDemo.app.event.event.CardIsPlayedEvent
|
|
||||||
import eventDemo.app.event.projection.GameState
|
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -25,29 +21,4 @@ data class IWantToPlayCardCommand(
|
|||||||
override val player: Player,
|
override val player: Player,
|
||||||
val card: Card,
|
val card: Card,
|
||||||
) : GameCommand.Payload
|
) : GameCommand.Payload
|
||||||
|
|
||||||
suspend fun run(
|
|
||||||
state: GameState,
|
|
||||||
eventHandler: GameEventHandler,
|
|
||||||
) {
|
|
||||||
if (!state.isStarted) {
|
|
||||||
throw CommandException("The game is Not started")
|
|
||||||
}
|
|
||||||
if (state.currentPlayerTurn != payload.player) {
|
|
||||||
throw CommandException("Its not your turn!")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.canBePlayThisCard(payload.player, payload.card)) {
|
|
||||||
eventHandler.handle(payload.aggregateId) {
|
|
||||||
CardIsPlayedEvent(
|
|
||||||
aggregateId = payload.aggregateId,
|
|
||||||
card = payload.card,
|
|
||||||
player = payload.player,
|
|
||||||
version = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw CommandException("You cannot play this card")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package eventDemo.app.command.command
|
package eventDemo.app.command.command
|
||||||
|
|
||||||
import eventDemo.app.command.CommandException
|
|
||||||
import eventDemo.app.entity.GameId
|
import eventDemo.app.entity.GameId
|
||||||
import eventDemo.app.entity.Player
|
import eventDemo.app.entity.Player
|
||||||
import eventDemo.app.event.GameEventHandler
|
|
||||||
import eventDemo.app.event.event.PlayerReadyEvent
|
|
||||||
import eventDemo.app.event.projection.GameState
|
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -23,29 +19,4 @@ data class IamReadyToPlayCommand(
|
|||||||
override val aggregateId: GameId,
|
override val aggregateId: GameId,
|
||||||
override val player: Player,
|
override val player: Player,
|
||||||
) : GameCommand.Payload
|
) : GameCommand.Payload
|
||||||
|
|
||||||
@Throws(CommandException::class)
|
|
||||||
suspend fun run(
|
|
||||||
state: GameState,
|
|
||||||
eventHandler: GameEventHandler,
|
|
||||||
) {
|
|
||||||
val playerExist: Boolean = state.players.contains(payload.player)
|
|
||||||
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(payload.player)
|
|
||||||
|
|
||||||
if (state.isStarted) {
|
|
||||||
throw CommandException("The game is already started")
|
|
||||||
} else if (!playerExist) {
|
|
||||||
throw CommandException("You are not in the game")
|
|
||||||
} else if (playerIsAlreadyReady) {
|
|
||||||
throw CommandException("You are already ready")
|
|
||||||
} else {
|
|
||||||
eventHandler.handle(payload.aggregateId) {
|
|
||||||
PlayerReadyEvent(
|
|
||||||
aggregateId = payload.aggregateId,
|
|
||||||
player = payload.player,
|
|
||||||
version = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.util.concurrent.locks.ReentrantLock
|
|||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stream to publish and read the played card event.
|
* Handle the event to dispatch it to store, bus and projections builders
|
||||||
*/
|
*/
|
||||||
class GameEventHandler(
|
class GameEventHandler(
|
||||||
private val eventBus: GameEventBus,
|
private val eventBus: GameEventBus,
|
||||||
@@ -23,17 +23,26 @@ class GameEventHandler(
|
|||||||
projectionsBuilders.add(builder)
|
projectionsBuilders.add(builder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Event, and send it to the event store and bus.
|
||||||
|
* Build also the projections.
|
||||||
|
*/
|
||||||
override fun handle(
|
override fun handle(
|
||||||
aggregateId: GameId,
|
aggregateId: GameId,
|
||||||
buildEvent: (version: Int) -> GameEvent,
|
buildEvent: (version: Int) -> GameEvent,
|
||||||
): GameEvent =
|
): GameEvent =
|
||||||
locks
|
locks
|
||||||
|
// Get lock for the aggregate
|
||||||
.computeIfAbsent(aggregateId) { ReentrantLock() }
|
.computeIfAbsent(aggregateId) { ReentrantLock() }
|
||||||
.withLock {
|
.withLock {
|
||||||
|
// Build event with the version
|
||||||
buildEvent(versionBuilder.buildNextVersion(aggregateId))
|
buildEvent(versionBuilder.buildNextVersion(aggregateId))
|
||||||
|
// then publish it to the event store
|
||||||
.also { eventStore.publish(it) }
|
.also { eventStore.publish(it) }
|
||||||
}.also { event ->
|
}.also { event ->
|
||||||
|
// Build the projections
|
||||||
projectionsBuilders.forEach { it(event) }
|
projectionsBuilders.forEach { it(event) }
|
||||||
|
// Publish to the bus
|
||||||
eventBus.publish(event)
|
eventBus.publish(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package eventDemo.app.eventListener
|
||||||
|
|
||||||
|
import eventDemo.app.notification.CommandSuccessNotification
|
||||||
|
import eventDemo.app.notification.Notification
|
||||||
|
import eventDemo.libs.command.CommandId
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
|
||||||
|
private fun SendChannel<Notification>.successNotifier(commandId: CommandId): suspend () -> Unit =
|
||||||
|
{
|
||||||
|
val logger = KotlinLogging.logger { }
|
||||||
|
CommandSuccessNotification(commandId = commandId)
|
||||||
|
.let { notification ->
|
||||||
|
logger.atDebug {
|
||||||
|
message = "Notification SUCCESS sent"
|
||||||
|
payload =
|
||||||
|
mapOf(
|
||||||
|
"notification" to notification,
|
||||||
|
"commandId" to commandId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
send(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,11 @@ import eventDemo.libs.command.Command
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
sealed interface CommandStateNotification : Notification
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ErrorNotification(
|
data class CommandErrorNotification(
|
||||||
@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,
|
||||||
val command: Command,
|
val command: Command,
|
||||||
) : Notification,
|
) : Notification,
|
||||||
CommandStateNotification
|
CommandNotification
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package eventDemo.app.notification
|
||||||
|
|
||||||
|
sealed interface CommandNotification : Notification
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package eventDemo.app.notification
|
||||||
|
|
||||||
|
import eventDemo.configuration.UUIDSerializer
|
||||||
|
import eventDemo.libs.command.CommandId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CommandSuccessNotification(
|
||||||
|
@Serializable(with = UUIDSerializer::class)
|
||||||
|
override val id: UUID = UUID.randomUUID(),
|
||||||
|
val commandId: CommandId,
|
||||||
|
) : Notification,
|
||||||
|
CommandNotification
|
||||||
@@ -9,7 +9,8 @@ import eventDemo.app.event.GameEventStore
|
|||||||
import eventDemo.app.event.projection.GameStateRepository
|
import eventDemo.app.event.projection.GameStateRepository
|
||||||
import eventDemo.app.event.projection.SnapshotConfig
|
import eventDemo.app.event.projection.SnapshotConfig
|
||||||
import eventDemo.app.eventListener.PlayerNotificationEventListener
|
import eventDemo.app.eventListener.PlayerNotificationEventListener
|
||||||
import eventDemo.libs.command.CommandStreamChannelBuilder
|
import eventDemo.libs.command.CommandRunnerController
|
||||||
|
import eventDemo.libs.command.CommandStreamChannel
|
||||||
import eventDemo.libs.event.EventBusInMemory
|
import eventDemo.libs.event.EventBusInMemory
|
||||||
import eventDemo.libs.event.EventStoreInMemory
|
import eventDemo.libs.event.EventStoreInMemory
|
||||||
import eventDemo.libs.event.VersionBuilder
|
import eventDemo.libs.event.VersionBuilder
|
||||||
@@ -41,12 +42,20 @@ val appKoinModule =
|
|||||||
GameStateRepository(get(), get(), snapshotConfig = SnapshotConfig())
|
GameStateRepository(get(), get(), snapshotConfig = SnapshotConfig())
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
CommandStreamChannelBuilder<GameCommand>()
|
CommandStreamChannel<GameCommand>(get())
|
||||||
|
}
|
||||||
|
single {
|
||||||
|
CommandRunnerController<GameCommand>()
|
||||||
|
}
|
||||||
|
single {
|
||||||
|
GameCommandHandler(get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
|
|
||||||
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
|
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
|
||||||
singleOf(::GameEventHandler)
|
singleOf(::GameEventHandler)
|
||||||
singleOf(::GameCommandActionRunner)
|
singleOf(::GameCommandActionRunner)
|
||||||
singleOf(::GameCommandHandler)
|
|
||||||
singleOf(::PlayerNotificationEventListener)
|
singleOf(::PlayerNotificationEventListener)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
configureActions()
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/main/kotlin/eventDemo/configuration/ConfigureDIAction.kt
Normal file
18
src/main/kotlin/eventDemo/configuration/ConfigureDIAction.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package eventDemo.configuration
|
||||||
|
|
||||||
|
import eventDemo.app.command.action.ICantPlay
|
||||||
|
import eventDemo.app.command.action.IWantToJoinTheGame
|
||||||
|
import eventDemo.app.command.action.IWantToPlayCard
|
||||||
|
import eventDemo.app.command.action.IamReadyToPlay
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure all actions
|
||||||
|
*/
|
||||||
|
fun Module.configureActions() {
|
||||||
|
singleOf(::IWantToPlayCard)
|
||||||
|
singleOf(::IamReadyToPlay)
|
||||||
|
singleOf(::IWantToJoinTheGame)
|
||||||
|
singleOf(::ICantPlay)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package eventDemo.libs.command
|
||||||
|
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls the execution of a command to prevent it from being executed more than once.
|
||||||
|
*/
|
||||||
|
class CommandRunnerController<C : Command>(
|
||||||
|
private val maxCacheTime: Duration = 10.minutes,
|
||||||
|
) {
|
||||||
|
private val executedCommand: ConcurrentHashMap<CommandId, Pair<Boolean, Instant>> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
suspend fun runOnlyOnce(
|
||||||
|
command: C,
|
||||||
|
action: CommandBlock<C>,
|
||||||
|
) {
|
||||||
|
if (!isAlreadyExecuted(command)) {
|
||||||
|
action(command)
|
||||||
|
setAsExecuted(command)
|
||||||
|
removeOldCache()
|
||||||
|
} else {
|
||||||
|
throw Exception("Command already executed", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAsExecuted(command: C) {
|
||||||
|
executedCommand.computeIfAbsent(command.id) { Pair(false, Clock.System.now()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeOldCache() {
|
||||||
|
executedCommand
|
||||||
|
.filterValues { (_, date) ->
|
||||||
|
(date + maxCacheTime) > Clock.System.now()
|
||||||
|
}.keys
|
||||||
|
.forEach {
|
||||||
|
executedCommand.remove(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAlreadyExecuted(command: C): Boolean =
|
||||||
|
executedCommand[command.id]?.first ?: false
|
||||||
|
|
||||||
|
class Exception(
|
||||||
|
override val message: String,
|
||||||
|
val command: Command,
|
||||||
|
) : kotlin.Exception(message)
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package eventDemo.libs.command
|
|
||||||
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represent a Command stream.
|
|
||||||
*
|
|
||||||
* The stream contains a list of all actions yet to be executed.
|
|
||||||
*/
|
|
||||||
interface CommandStream<C : Command> {
|
|
||||||
/**
|
|
||||||
* A class to implement success/failed action.
|
|
||||||
*/
|
|
||||||
interface ComputeStatus {
|
|
||||||
suspend fun ack()
|
|
||||||
|
|
||||||
suspend fun nack()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply an action to all command income in the stream.
|
|
||||||
*/
|
|
||||||
suspend fun process(action: CommandBlock<C>)
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
fun blockAndProcess(action: CommandBlock<C>) {
|
|
||||||
GlobalScope.launch {
|
|
||||||
process(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias CommandBlock<C> = suspend CommandStream.ComputeStatus.(C) -> Unit
|
|
||||||
@@ -2,95 +2,56 @@ package eventDemo.libs.command
|
|||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
|
|
||||||
class CommandStreamChannelBuilder<C : Command>(
|
|
||||||
private val maxCacheTime: Duration = 10.minutes,
|
|
||||||
) {
|
|
||||||
operator fun invoke(incoming: ReceiveChannel<C>): CommandStreamChannel<C> =
|
|
||||||
CommandStreamChannel(incoming, maxCacheTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manage [Command]'s with kotlin Channel
|
* Manage [Command]'s with kotlin Channel.
|
||||||
|
*
|
||||||
|
* Use [CommandRunnerController] to prevent multiple executions.
|
||||||
|
*
|
||||||
|
* Add logs when command success or failed
|
||||||
*/
|
*/
|
||||||
class CommandStreamChannel<C : Command>(
|
class CommandStreamChannel<C : Command>(
|
||||||
private val incoming: ReceiveChannel<C>,
|
private val controller: CommandRunnerController<C>,
|
||||||
private val maxCacheTime: Duration = 10.minutes,
|
) {
|
||||||
) : CommandStream<C> {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val executedCommand: ConcurrentHashMap<CommandId, Pair<Boolean, Instant>> = ConcurrentHashMap()
|
|
||||||
|
|
||||||
override suspend fun process(action: CommandBlock<C>) {
|
suspend fun process(
|
||||||
|
incoming: ReceiveChannel<C>,
|
||||||
|
action: CommandBlock<C>,
|
||||||
|
) {
|
||||||
for (command in incoming) {
|
for (command in incoming) {
|
||||||
val now = Clock.System.now()
|
try {
|
||||||
val (status, _) = executedCommand.computeIfAbsent(command.id) { Pair(false, now) }
|
controller.runOnlyOnce(command) {
|
||||||
|
// Wrap action to add logs
|
||||||
if (status) {
|
runAndLogStatus(command, action)
|
||||||
|
}
|
||||||
|
} catch (e: CommandRunnerController.Exception) {
|
||||||
logger.atWarn {
|
logger.atWarn {
|
||||||
message = "Command already executed: $command"
|
message = e.message
|
||||||
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(
|
private suspend fun runAndLogStatus(
|
||||||
command: C,
|
command: C,
|
||||||
action: CommandBlock<C>,
|
action: CommandBlock<C>,
|
||||||
) {
|
) {
|
||||||
val status =
|
val actionResult = runCatching { action(command) }
|
||||||
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) {
|
if (actionResult.isFailure) {
|
||||||
logger.atInfo {
|
logger.atWarn {
|
||||||
message = "Error on compute the Command: $command"
|
message = "Compute command FAILED: $command"
|
||||||
payload = mapOf("command" to command)
|
payload = mapOf("command" to command)
|
||||||
cause = actionResult.exceptionOrNull()
|
cause = actionResult.exceptionOrNull()
|
||||||
}
|
}
|
||||||
markAsFailed(command)
|
} else if (actionResult.isSuccess) {
|
||||||
} else if (!status.isSet) {
|
logger.atInfo {
|
||||||
status.ack()
|
message = "Compute command SUCCESS: $command"
|
||||||
}
|
payload = mapOf("command" to command)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typealias CommandBlock<C> = suspend (C) -> Unit
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import eventDemo.app.entity.GameId
|
|||||||
import eventDemo.app.entity.Player
|
import eventDemo.app.entity.Player
|
||||||
import eventDemo.app.eventListener.PlayerNotificationEventListener
|
import eventDemo.app.eventListener.PlayerNotificationEventListener
|
||||||
import eventDemo.app.eventListener.ReactionEventListener
|
import eventDemo.app.eventListener.ReactionEventListener
|
||||||
|
import eventDemo.app.notification.CommandSuccessNotification
|
||||||
import eventDemo.app.notification.Notification
|
import eventDemo.app.notification.Notification
|
||||||
import eventDemo.app.notification.WelcomeToTheGameNotification
|
import eventDemo.app.notification.WelcomeToTheGameNotification
|
||||||
import eventDemo.configuration.appKoinModule
|
import eventDemo.configuration.appKoinModule
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.collections.shouldContain
|
import io.kotest.matchers.collections.shouldContain
|
||||||
|
import io.kotest.matchers.equals.shouldBeEqual
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -36,7 +38,12 @@ class GameCommandHandlerTest :
|
|||||||
commandHandler.handle(player, channelCommand, channelNotification)
|
commandHandler.handle(player, channelCommand, channelNotification)
|
||||||
}
|
}
|
||||||
|
|
||||||
channelCommand.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)))
|
IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(gameId, player)).also { sendCommand ->
|
||||||
|
channelCommand.send(sendCommand)
|
||||||
|
channelNotification.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
assertIs<WelcomeToTheGameNotification>(channelNotification.receive()).let {
|
assertIs<WelcomeToTheGameNotification>(channelNotification.receive()).let {
|
||||||
it.players shouldContain player
|
it.players shouldContain player
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import eventDemo.app.event.projection.ProjectionSnapshotRepositoryInMemory
|
|||||||
import eventDemo.app.event.projection.apply
|
import eventDemo.app.event.projection.apply
|
||||||
import eventDemo.app.eventListener.PlayerNotificationEventListener
|
import eventDemo.app.eventListener.PlayerNotificationEventListener
|
||||||
import eventDemo.app.eventListener.ReactionEventListener
|
import eventDemo.app.eventListener.ReactionEventListener
|
||||||
|
import eventDemo.app.notification.CommandSuccessNotification
|
||||||
import eventDemo.app.notification.ItsTheTurnOfNotification
|
import eventDemo.app.notification.ItsTheTurnOfNotification
|
||||||
import eventDemo.app.notification.Notification
|
import eventDemo.app.notification.Notification
|
||||||
import eventDemo.app.notification.PlayerAsJoinTheGameNotification
|
import eventDemo.app.notification.PlayerAsJoinTheGameNotification
|
||||||
@@ -59,14 +60,25 @@ class GameStateTest :
|
|||||||
|
|
||||||
val player1Job =
|
val player1Job =
|
||||||
launch {
|
launch {
|
||||||
channelCommand1.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1)))
|
IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player1)).also { sendCommand ->
|
||||||
|
channelCommand1.send(sendCommand)
|
||||||
|
channelNotification1.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
channelNotification1.receive().let {
|
channelNotification1.receive().let {
|
||||||
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1)
|
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1)
|
||||||
}
|
}
|
||||||
channelNotification1.receive().let {
|
channelNotification1.receive().let {
|
||||||
assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2
|
assertIs<PlayerAsJoinTheGameNotification>(it).player shouldBeEqual player2
|
||||||
}
|
}
|
||||||
channelCommand1.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)))
|
IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player1)).also { sendCommand ->
|
||||||
|
channelCommand1.send(sendCommand)
|
||||||
|
channelNotification1.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
channelNotification1.receive().let {
|
channelNotification1.receive().let {
|
||||||
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2
|
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player2
|
||||||
}
|
}
|
||||||
@@ -80,7 +92,13 @@ class GameStateTest :
|
|||||||
player shouldBeEqual player1
|
player shouldBeEqual player1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
channelCommand1.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())))
|
|
||||||
|
IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player1, player1Hand.first())).also { sendCommand ->
|
||||||
|
channelCommand1.send(sendCommand)
|
||||||
|
channelNotification1.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
channelNotification1.receive().let {
|
channelNotification1.receive().let {
|
||||||
assertIs<ItsTheTurnOfNotification>(it).apply {
|
assertIs<ItsTheTurnOfNotification>(it).apply {
|
||||||
@@ -99,14 +117,27 @@ class GameStateTest :
|
|||||||
val player2Job =
|
val player2Job =
|
||||||
launch {
|
launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
channelCommand2.send(IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)))
|
IWantToJoinTheGameCommand(IWantToJoinTheGameCommand.Payload(id, player2)).also { sendCommand ->
|
||||||
|
channelCommand2.send(sendCommand)
|
||||||
|
channelNotification2.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
channelNotification2.receive().let {
|
channelNotification2.receive().let {
|
||||||
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2)
|
assertIs<WelcomeToTheGameNotification>(it).players shouldBeEqual setOf(player1, player2)
|
||||||
}
|
}
|
||||||
channelNotification2.receive().let {
|
channelNotification2.receive().let {
|
||||||
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1
|
assertIs<PlayerWasReadyNotification>(it).player shouldBeEqual player1
|
||||||
}
|
}
|
||||||
channelCommand2.send(IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)))
|
|
||||||
|
IamReadyToPlayCommand(IamReadyToPlayCommand.Payload(id, player2)).also { sendCommand ->
|
||||||
|
channelCommand2.send(sendCommand)
|
||||||
|
channelNotification2.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val player2Hand =
|
val player2Hand =
|
||||||
channelNotification2.receive().let {
|
channelNotification2.receive().let {
|
||||||
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
|
assertIs<TheGameWasStartedNotification>(it).hand shouldHaveSize 7
|
||||||
@@ -129,7 +160,13 @@ class GameStateTest :
|
|||||||
player shouldBeEqual player2
|
player shouldBeEqual player2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
channelCommand2.send(IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())))
|
|
||||||
|
IWantToPlayCardCommand(IWantToPlayCardCommand.Payload(id, player2, player2Hand.first())).also { sendCommand ->
|
||||||
|
channelCommand2.send(sendCommand)
|
||||||
|
channelNotification2.receive().let {
|
||||||
|
assertIs<CommandSuccessNotification>(it).commandId shouldBeEqual sendCommand.id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
koinApplication { modules(appKoinModule) }.koin.apply {
|
koinApplication { modules(appKoinModule) }.koin.apply {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package eventDemo.libs.command
|
|||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -11,6 +14,7 @@ class CommandTest(
|
|||||||
override val id: CommandId,
|
override val id: CommandId,
|
||||||
) : Command
|
) : Command
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
class CommandStreamChannelTest :
|
class CommandStreamChannelTest :
|
||||||
FunSpec({
|
FunSpec({
|
||||||
|
|
||||||
@@ -18,15 +22,17 @@ class CommandStreamChannelTest :
|
|||||||
val command = CommandTest(CommandId())
|
val command = CommandTest(CommandId())
|
||||||
|
|
||||||
val channel = Channel<CommandTest>()
|
val channel = Channel<CommandTest>()
|
||||||
val stream =
|
val stream = CommandStreamChannel(CommandRunnerController())
|
||||||
CommandStreamChannel(channel)
|
|
||||||
|
|
||||||
val spyCall: () -> Unit = mockk(relaxed = true)
|
val spyCall: () -> Unit = mockk(relaxed = true)
|
||||||
|
|
||||||
stream.blockAndProcess {
|
GlobalScope.launch {
|
||||||
println("In action ${it.id}")
|
stream.process(channel) {
|
||||||
spyCall()
|
println("In action ${it.id}")
|
||||||
|
spyCall()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.send(command)
|
channel.send(command)
|
||||||
verify(exactly = 1) { spyCall() }
|
verify(exactly = 1) { spyCall() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user