Move file and add interface to improve Archi Hexa
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
package eventDemo.business.command
|
||||
|
||||
class CommandException(
|
||||
override val message: String,
|
||||
) : Exception(message)
|
||||
@@ -0,0 +1,27 @@
|
||||
package eventDemo.business.command
|
||||
|
||||
import eventDemo.business.command.action.ICantPlay
|
||||
import eventDemo.business.command.action.IWantToJoinTheGame
|
||||
import eventDemo.business.command.action.IWantToPlayCard
|
||||
import eventDemo.business.command.action.IamReadyToPlay
|
||||
import eventDemo.business.command.command.GameCommand
|
||||
import eventDemo.business.command.command.ICantPlayCommand
|
||||
import eventDemo.business.command.command.IWantToJoinTheGameCommand
|
||||
import eventDemo.business.command.command.IWantToPlayCardCommand
|
||||
import eventDemo.business.command.command.IamReadyToPlayCommand
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
|
||||
class GameCommandActionRunner(
|
||||
private val iWantToPlayCard: IWantToPlayCard,
|
||||
private val iamReadyToPlay: IamReadyToPlay,
|
||||
private val iWantToJoinTheGame: IWantToJoinTheGame,
|
||||
private val iCantPlay: ICantPlay,
|
||||
) {
|
||||
fun run(command: GameCommand): (version: Int) -> GameEvent =
|
||||
when (command) {
|
||||
is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
|
||||
is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
|
||||
is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
|
||||
is ICantPlayCommand -> iCantPlay.run(command)
|
||||
}
|
||||
}
|
||||
130
src/main/kotlin/eventDemo/business/command/GameCommandHandler.kt
Normal file
130
src/main/kotlin/eventDemo/business/command/GameCommandHandler.kt
Normal file
@@ -0,0 +1,130 @@
|
||||
package eventDemo.business.command
|
||||
|
||||
import eventDemo.business.command.command.GameCommand
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.GameEventHandler
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.notification.CommandErrorNotification
|
||||
import eventDemo.business.notification.CommandSuccessNotification
|
||||
import eventDemo.business.notification.Notification
|
||||
import eventDemo.libs.command.CommandId
|
||||
import eventDemo.libs.command.CommandStreamChannel
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class GameCommandHandler(
|
||||
private val commandStreamChannel: CommandStreamChannel<GameCommand>,
|
||||
private val eventHandler: GameEventHandler,
|
||||
private val runner: GameCommandActionRunner,
|
||||
eventBus: GameEventBus,
|
||||
listenerPriority: Int = DEFAULT_PRIORITY,
|
||||
) {
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
player: Player,
|
||||
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
||||
channelNotification: SendChannel<Notification>,
|
||||
) {
|
||||
commandStreamChannel.process(incomingCommandChannel) { command ->
|
||||
withLoggingContext("command" to command.toString()) {
|
||||
if (command.payload.player.id != player.id) {
|
||||
logger.warn { "Handle command Refuse, the player of the command is not the same" }
|
||||
channelNotification.sendError(command)("You are not the author of this command")
|
||||
} else {
|
||||
logger.info { "Handle 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.warn(e) { e.message }
|
||||
channelNotification.sendError(command)(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SendChannel<Notification>.sendSuccess(commandId: CommandId): suspend () -> Unit =
|
||||
{
|
||||
val logger = KotlinLogging.logger { }
|
||||
CommandSuccessNotification(commandId = commandId)
|
||||
.also { notification ->
|
||||
withLoggingContext("notification" to notification.toString(), "commandId" to commandId.toString()) {
|
||||
logger.debug { "Notification SUCCESS sent" }
|
||||
send(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SendChannel<Notification>.sendError(command: GameCommand): suspend (message: String) -> Unit =
|
||||
{
|
||||
val logger = KotlinLogging.logger { }
|
||||
CommandErrorNotification(message = it, command = command)
|
||||
.also { notification ->
|
||||
withLoggingContext("notification" to notification.toString(), "command" to command.toString()) {
|
||||
logger.warn { "Notification ERROR sent: ${notification.message}" }
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.libs.command.Command
|
||||
import eventDemo.libs.event.Event
|
||||
|
||||
sealed interface CommandAction<C : Command, E : Event<*>> {
|
||||
fun run(command: C): (version: Int) -> E
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.ICantPlayCommand
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.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): (version: 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.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.IWantToJoinTheGameCommand
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.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): (version: 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.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.IWantToPlayCardCommand
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.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): (version: 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.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.IamReadyToPlayCommand
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.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): (version: 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.Command
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface GameCommand : Command {
|
||||
val payload: Payload
|
||||
|
||||
@Serializable
|
||||
sealed interface Payload {
|
||||
val aggregateId: GameId
|
||||
val player: Player
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
@Serializable
|
||||
data class ICantPlayCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
@Serializable
|
||||
data class IWantToJoinTheGameCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
@Serializable
|
||||
data class IWantToPlayCardCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
val card: Card,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to set as ready to play
|
||||
*/
|
||||
@Serializable
|
||||
data class IamReadyToPlayCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
109
src/main/kotlin/eventDemo/business/entity/Card.kt
Normal file
109
src/main/kotlin/eventDemo/business/entity/Card.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* A Play card
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface Card {
|
||||
val id: UUID
|
||||
|
||||
/**
|
||||
* The color of a card
|
||||
*/
|
||||
@Serializable
|
||||
enum class Color {
|
||||
Blue,
|
||||
Red,
|
||||
Yellow,
|
||||
Green,
|
||||
}
|
||||
|
||||
sealed interface ColorCard : Card {
|
||||
val color: Color
|
||||
}
|
||||
|
||||
/**
|
||||
* A play card with color and number
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Simple")
|
||||
data class NumericCard(
|
||||
val number: Int,
|
||||
override val color: Color,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Card,
|
||||
ColorCard
|
||||
|
||||
sealed interface Special : Card
|
||||
|
||||
/**
|
||||
* A revert card to revert the order of the turn.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Reverse")
|
||||
data class ReverseCard(
|
||||
override val color: Color,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
ColorCard
|
||||
|
||||
sealed interface PassTurnCard : Card
|
||||
|
||||
/**
|
||||
* A pass card to pass the turn of the next player.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Pass")
|
||||
data class PassCard(
|
||||
override val color: Color,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
ColorCard,
|
||||
PassTurnCard
|
||||
|
||||
/**
|
||||
* A play card to force the next player to take 2 card and pass the turn.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Plus2")
|
||||
data class Plus2Card(
|
||||
override val color: Color,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
ColorCard,
|
||||
PassTurnCard
|
||||
|
||||
sealed interface AllColorCard : Card
|
||||
|
||||
/**
|
||||
* A play card to force the next player to take 4 card and pass the turn.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("Plus4")
|
||||
class Plus4Card(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
AllColorCard,
|
||||
PassTurnCard
|
||||
|
||||
/**
|
||||
* A play card to change the color.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ChangeColor")
|
||||
class ChangeColorCard(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
AllColorCard
|
||||
}
|
||||
133
src/main/kotlin/eventDemo/business/entity/Deck.kt
Normal file
133
src/main/kotlin/eventDemo/business/entity/Deck.kt
Normal file
@@ -0,0 +1,133 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Deck(
|
||||
val stack: Stack = Stack(),
|
||||
val discard: Discard = Discard(),
|
||||
val playersHands: PlayersHands = PlayersHands(),
|
||||
) {
|
||||
constructor(players: Set<Player>) :
|
||||
this(playersHands = PlayersHands(players))
|
||||
|
||||
fun shuffle(): Deck =
|
||||
copy(stack = stack.shuffle())
|
||||
|
||||
fun placeFirstCardOnDiscard(): Deck {
|
||||
val takenCard = stack.first()
|
||||
return copy(
|
||||
stack = stack - takenCard,
|
||||
discard = discard + takenCard,
|
||||
)
|
||||
}
|
||||
|
||||
fun takeOneCardFromStackTo(player: Player): Deck =
|
||||
takeOne().let { (deck, newPlayerCard) ->
|
||||
deck.copy(
|
||||
playersHands = deck.playersHands.addCard(player, newPlayerCard),
|
||||
)
|
||||
}
|
||||
|
||||
fun putOneCardFromHand(
|
||||
player: Player,
|
||||
card: Card,
|
||||
): 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 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(
|
||||
players: Set<Player>,
|
||||
handSize: Int = 7,
|
||||
): Deck {
|
||||
// Copy cards from stack to the player hands
|
||||
val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
|
||||
return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
|
||||
val hand = acc.stack.take(handSize)
|
||||
val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
|
||||
copy(
|
||||
stack = newStack,
|
||||
playersHands = acc.playersHands.addCards(player, hand),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class Stack(
|
||||
private val cards: Set<Card> = emptySet(),
|
||||
) : Set<Card> by cards {
|
||||
operator fun plus(card: Card): Stack =
|
||||
cards.plus(card).toStack()
|
||||
|
||||
operator fun minus(card: Card): Stack =
|
||||
cards.minus(card).toStack()
|
||||
|
||||
fun shuffle(): Stack =
|
||||
shuffled().toStack()
|
||||
}
|
||||
|
||||
fun List<Card>.toStack(): Stack =
|
||||
Stack(this.toSet())
|
||||
|
||||
fun Set<Card>.toStack(): Stack =
|
||||
Stack(this)
|
||||
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class Discard(
|
||||
private val cards: Set<Card> = emptySet(),
|
||||
) : Set<Card> by cards {
|
||||
operator fun plus(card: Card): Discard =
|
||||
cards.plus(card).toDiscard()
|
||||
|
||||
operator fun minus(card: Card): Discard =
|
||||
cards.minus(card).toDiscard()
|
||||
}
|
||||
|
||||
fun List<Card>.toDiscard(): Discard =
|
||||
Discard(this.toSet())
|
||||
|
||||
fun Set<Card>.toDiscard(): Discard =
|
||||
Discard(this)
|
||||
18
src/main/kotlin/eventDemo/business/entity/GameId.kt
Normal file
18
src/main/kotlin/eventDemo/business/entity/GameId.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import eventDemo.configuration.GameIdSerializer
|
||||
import eventDemo.libs.event.AggregateId
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [AggregateId] for a game.
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable(with = GameIdSerializer::class)
|
||||
value class GameId(
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : AggregateId {
|
||||
override fun toString(): String =
|
||||
id.toString()
|
||||
}
|
||||
29
src/main/kotlin/eventDemo/business/entity/Player.kt
Normal file
29
src/main/kotlin/eventDemo/business/entity/Player.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import eventDemo.configuration.PlayerIdSerializer
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import eventDemo.libs.event.AggregateId
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class Player(
|
||||
val name: String,
|
||||
@Serializable(with = PlayerIdSerializer::class)
|
||||
val id: PlayerId = PlayerId(UUID.randomUUID()),
|
||||
) {
|
||||
constructor(id: String, name: String) : this(
|
||||
name,
|
||||
PlayerId(UUID.fromString(id)),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PlayerId(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : AggregateId {
|
||||
override fun toString(): String =
|
||||
id.toString()
|
||||
}
|
||||
}
|
||||
50
src/main/kotlin/eventDemo/business/entity/PlayersHands.kt
Normal file
50
src/main/kotlin/eventDemo/business/entity/PlayersHands.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PlayersHands(
|
||||
private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
|
||||
) : Map<Player.PlayerId, List<Card>> by map {
|
||||
constructor(players: Set<Player>) :
|
||||
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
|
||||
|
||||
fun getHand(player: Player): List<Card>? =
|
||||
this[player.id]
|
||||
|
||||
fun removeCard(
|
||||
player: Player,
|
||||
card: Card,
|
||||
): PlayersHands =
|
||||
mapValues { (playerId, cards) ->
|
||||
if (playerId == player.id) {
|
||||
if (!cards.contains(card)) error("The hand no contain the card")
|
||||
cards - card
|
||||
} else {
|
||||
cards
|
||||
}
|
||||
}.toPlayersHands()
|
||||
|
||||
fun addCard(
|
||||
player: Player,
|
||||
newCard: Card,
|
||||
): PlayersHands =
|
||||
addCards(player, listOf(newCard))
|
||||
|
||||
fun addCards(
|
||||
player: Player,
|
||||
newCards: List<Card>,
|
||||
): PlayersHands =
|
||||
mapValues { (p, cards) ->
|
||||
if (p == player.id) {
|
||||
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
|
||||
cards + newCards
|
||||
} else {
|
||||
cards
|
||||
}
|
||||
}.toPlayersHands()
|
||||
}
|
||||
|
||||
fun Map<Player.PlayerId, List<Card>>.toPlayersHands(): PlayersHands =
|
||||
PlayersHands(this)
|
||||
16
src/main/kotlin/eventDemo/business/event/EventHandler.kt
Normal file
16
src/main/kotlin/eventDemo/business/event/EventHandler.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.libs.event.AggregateId
|
||||
import eventDemo.libs.event.Event
|
||||
|
||||
/**
|
||||
* A stream to publish and read the played card event.
|
||||
*/
|
||||
interface EventHandler<E : Event<ID>, ID : AggregateId> {
|
||||
fun registerProjectionBuilder(builder: (event: E) -> Unit)
|
||||
|
||||
fun handle(
|
||||
aggregateId: ID,
|
||||
buildEvent: (version: Int) -> E,
|
||||
): E
|
||||
}
|
||||
7
src/main/kotlin/eventDemo/business/event/GameEventBus.kt
Normal file
7
src/main/kotlin/eventDemo/business/event/GameEventBus.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.EventBus
|
||||
|
||||
interface GameEventBus : EventBus<GameEvent, GameId>
|
||||
57
src/main/kotlin/eventDemo/business/event/GameEventHandler.kt
Normal file
57
src/main/kotlin/eventDemo/business/event/GameEventHandler.kt
Normal file
@@ -0,0 +1,57 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.VersionBuilder
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
/**
|
||||
* Handle the event to dispatch it to store, bus and projections builders
|
||||
*/
|
||||
class GameEventHandler(
|
||||
private val eventBus: GameEventBus,
|
||||
private val eventStore: GameEventStore,
|
||||
private val versionBuilder: VersionBuilder,
|
||||
) : EventHandler<GameEvent, GameId> {
|
||||
private val projectionsBuilders: ConcurrentLinkedQueue<(GameEvent) -> Unit> = ConcurrentLinkedQueue()
|
||||
private val locks: ConcurrentHashMap<GameId, ReentrantLock> = ConcurrentHashMap()
|
||||
|
||||
override fun registerProjectionBuilder(builder: (event: GameEvent) -> Unit) {
|
||||
projectionsBuilders.add(builder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Event, and send it to the event store and bus.
|
||||
* Build also the projections.
|
||||
*/
|
||||
override fun handle(
|
||||
aggregateId: GameId,
|
||||
buildEvent: (version: Int) -> GameEvent,
|
||||
): GameEvent =
|
||||
withLoggingContext("aggregateId" to aggregateId.toString()) {
|
||||
locks
|
||||
// Get lock for the aggregate
|
||||
.computeIfAbsent(aggregateId) { ReentrantLock() }
|
||||
.withLock {
|
||||
// Build event with the version
|
||||
buildEvent(versionBuilder.buildNextVersion(aggregateId))
|
||||
// then publish it to the event store
|
||||
.also {
|
||||
withLoggingContext("event" to it.toString()) {
|
||||
eventStore.publish(it)
|
||||
}
|
||||
}
|
||||
}.also { event ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
// Build the projections
|
||||
projectionsBuilders.forEach { it(event) }
|
||||
// Publish to the bus
|
||||
eventBus.publish(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.EventStore
|
||||
|
||||
interface GameEventStore : EventStore<GameEvent, GameId>
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [GameEvent] to represent a played card.
|
||||
*/
|
||||
data class CardIsPlayedEvent(
|
||||
override val aggregateId: GameId,
|
||||
val card: Card,
|
||||
override val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent,
|
||||
PlayerActionEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
16
src/main/kotlin/eventDemo/business/event/event/GameEvent.kt
Normal file
16
src/main/kotlin/eventDemo/business/event/event/GameEvent.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.libs.event.Event
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [Event] of a Game.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface GameEvent : Event<GameId> {
|
||||
override val eventId: UUID
|
||||
override val aggregateId: GameId
|
||||
override val version: Int
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Deck
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.entity.initHands
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when all players are ready.
|
||||
*/
|
||||
data class GameStartedEvent(
|
||||
override val aggregateId: GameId,
|
||||
val firstPlayer: Player,
|
||||
val deck: Deck,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
|
||||
companion object {
|
||||
fun new(
|
||||
id: GameId,
|
||||
players: Set<Player>,
|
||||
shuffleIsDisabled: Boolean = isDisabled,
|
||||
version: Int,
|
||||
): GameStartedEvent =
|
||||
GameStartedEvent(
|
||||
aggregateId = id,
|
||||
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
|
||||
deck =
|
||||
Deck
|
||||
.newWithoutPlayers()
|
||||
.let { if (shuffleIsDisabled) it else it.shuffle() }
|
||||
.initHands(players)
|
||||
.placeFirstCardOnDiscard(),
|
||||
version = version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var isDisabled = false
|
||||
|
||||
internal fun disableShuffleDeck() {
|
||||
isDisabled = true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [GameEvent] to represent a new player joining the game.
|
||||
*/
|
||||
data class NewPlayerEvent(
|
||||
override val aggregateId: GameId,
|
||||
val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
|
||||
sealed interface PlayerActionEvent : GameEvent {
|
||||
val player: Player
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player chose a color.
|
||||
*/
|
||||
data class PlayerChoseColorEvent(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
val color: Card.Color,
|
||||
override val version: Int,
|
||||
) : GameEvent,
|
||||
PlayerActionEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player can play.
|
||||
*/
|
||||
data class PlayerHavePassEvent(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
val takenCard: Card,
|
||||
override val version: Int,
|
||||
) : GameEvent,
|
||||
PlayerActionEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player is ready.
|
||||
*/
|
||||
data class PlayerReadyEvent(
|
||||
override val aggregateId: GameId,
|
||||
val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player is ready.
|
||||
*/
|
||||
data class PlayerWinEvent(
|
||||
override val aggregateId: GameId,
|
||||
val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package eventDemo.business.event.eventListener
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
import eventDemo.business.notification.ItsTheTurnOfNotification
|
||||
import eventDemo.business.notification.Notification
|
||||
import eventDemo.business.notification.PlayerAsJoinTheGameNotification
|
||||
import eventDemo.business.notification.PlayerAsPlayACardNotification
|
||||
import eventDemo.business.notification.PlayerHavePassNotification
|
||||
import eventDemo.business.notification.PlayerWasChoseTheCardColorNotification
|
||||
import eventDemo.business.notification.PlayerWasReadyNotification
|
||||
import eventDemo.business.notification.PlayerWinNotification
|
||||
import eventDemo.business.notification.TheGameWasStartedNotification
|
||||
import eventDemo.business.notification.WelcomeToTheGameNotification
|
||||
import eventDemo.business.notification.YourNewCardNotification
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
|
||||
class PlayerNotificationEventListener(
|
||||
private val eventBus: GameEventBus,
|
||||
private val gameStateRepository: GameStateRepository,
|
||||
) {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
fun startListening(
|
||||
outgoingNotification: (Notification) -> Unit,
|
||||
currentPlayer: Player,
|
||||
) {
|
||||
eventBus.subscribe { event: GameEvent ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
val currentState = gameStateRepository.getUntil(event)
|
||||
|
||||
fun Notification.send() {
|
||||
withLoggingContext("notification" to this.toString()) {
|
||||
if (currentState.players.contains(currentPlayer)) {
|
||||
// Only notify players who have already joined the game.
|
||||
outgoingNotification(this)
|
||||
logger.info { "Notification was SEND" }
|
||||
} else {
|
||||
// Rare use case, when a connexion is created with the channel,
|
||||
// but the player was not already join in the game
|
||||
logger.warn { "Notification was SKIP, no player on the game" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package eventDemo.business.event.eventListener
|
||||
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.GameEventHandler
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
|
||||
class ReactionEventListener(
|
||||
private val eventBus: GameEventBus,
|
||||
private val eventHandler: GameEventHandler,
|
||||
private val gameStateRepository: GameStateRepository,
|
||||
private val priority: Int = DEFAULT_PRIORITY,
|
||||
) {
|
||||
companion object Config {
|
||||
const val DEFAULT_PRIORITY = -1000
|
||||
}
|
||||
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
fun init() {
|
||||
eventBus.subscribe(priority) { event: GameEvent ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
val state = gameStateRepository.getUntil(event)
|
||||
sendStartGameEvent(state, event)
|
||||
sendWinnerEvent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendStartGameEvent(
|
||||
state: GameState,
|
||||
event: GameEvent,
|
||||
) {
|
||||
if (state.isReady && !state.isStarted) {
|
||||
val reactionEvent =
|
||||
eventHandler.handle(state.aggregateId) {
|
||||
GameStartedEvent.new(
|
||||
id = state.aggregateId,
|
||||
players = state.players,
|
||||
version = it,
|
||||
)
|
||||
}
|
||||
logger.atInfo {
|
||||
message = "Reaction event was Send"
|
||||
payload = mapOf("reactionEvent" to reactionEvent)
|
||||
}
|
||||
} else {
|
||||
if (event is PlayerReadyEvent) {
|
||||
logger.info { "All players was not ready ${state.readyPlayers}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendWinnerEvent(state: GameState) {
|
||||
val winner = state.playerHasNoCardLeft().firstOrNull()
|
||||
if (winner != null) {
|
||||
val reactionEvent =
|
||||
eventHandler.handle(state.aggregateId) {
|
||||
PlayerWinEvent(
|
||||
aggregateId = state.aggregateId,
|
||||
player = winner,
|
||||
version = it,
|
||||
)
|
||||
}
|
||||
|
||||
logger.atInfo {
|
||||
message = "Reaction event was Send"
|
||||
payload = mapOf("reactionEvent" to reactionEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
src/main/kotlin/eventDemo/business/event/projection/GameState.kt
Normal file
181
src/main/kotlin/eventDemo/business/event/projection/GameState.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Deck
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GameState(
|
||||
override val aggregateId: GameId,
|
||||
override val lastEventVersion: Int = 0,
|
||||
val players: Set<Player> = emptySet(),
|
||||
val currentPlayerTurn: Player? = null,
|
||||
val cardOnCurrentStack: LastCard? = null,
|
||||
val colorOnCurrentStack: Card.Color? = null,
|
||||
val direction: Direction = Direction.CLOCKWISE,
|
||||
val readyPlayers: Set<Player> = emptySet(),
|
||||
val deck: Deck = Deck(players),
|
||||
val isStarted: Boolean = false,
|
||||
val playerWins: Set<Player> = emptySet(),
|
||||
) : Projection<GameId> {
|
||||
@Serializable
|
||||
data class LastCard(
|
||||
val card: Card,
|
||||
val player: Player,
|
||||
)
|
||||
|
||||
enum class Direction {
|
||||
CLOCKWISE,
|
||||
COUNTER_CLOCKWISE,
|
||||
;
|
||||
|
||||
fun revert(): Direction =
|
||||
if (this === CLOCKWISE) {
|
||||
COUNTER_CLOCKWISE
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.event.event.PlayerActionEvent
|
||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
fun GameState.apply(event: GameEvent): GameState =
|
||||
this.let { state ->
|
||||
val logger = KotlinLogging.logger { }
|
||||
if (event is PlayerActionEvent) {
|
||||
if (state.currentPlayerTurn != event.player) {
|
||||
logger.atError {
|
||||
message = "Inconsistent player turn"
|
||||
payload =
|
||||
mapOf(
|
||||
"CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
|
||||
"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" }
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
|
||||
interface GameStateRepository {
|
||||
fun getLast(gameId: GameId): GameState
|
||||
|
||||
fun getUntil(event: GameEvent): GameState
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.libs.event.AggregateId
|
||||
|
||||
interface Projection<ID : AggregateId> {
|
||||
val aggregateId: ID
|
||||
val lastEventVersion: Int
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import eventDemo.libs.command.Command
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class CommandErrorNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val message: String,
|
||||
val command: Command,
|
||||
) : Notification,
|
||||
CommandNotification
|
||||
@@ -0,0 +1,3 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
sealed interface CommandNotification : Notification
|
||||
@@ -0,0 +1,14 @@
|
||||
package eventDemo.business.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
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class ItsTheTurnOfNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
) : Notification
|
||||
@@ -0,0 +1,11 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
sealed interface Notification {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerAsJoinTheGameNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
) : Notification
|
||||
@@ -0,0 +1,15 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerAsPlayACardNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
val card: Card,
|
||||
) : Notification
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerHavePassNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
) : Notification
|
||||
@@ -0,0 +1,15 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerWasChoseTheCardColorNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
val color: Card.Color,
|
||||
) : Notification
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerWasReadyNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
) : Notification
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerWinNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
) : Notification
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class TheGameWasStartedNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val hand: List<Card>,
|
||||
) : Notification
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class WelcomeToTheGameNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val players: Set<Player>,
|
||||
) : Notification
|
||||
@@ -0,0 +1,13 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.configuration.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class YourNewCardNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val card: Card,
|
||||
) : Notification
|
||||
Reference in New Issue
Block a user