refactoring

This commit is contained in:
2025-03-04 23:21:06 +01:00
parent f3ca94c97e
commit 06443d7efa
31 changed files with 140 additions and 185 deletions

View File

@@ -1,13 +1,14 @@
package eventDemo.app.actions
import eventDemo.app.actions.playNewCard.PlayCardCommand
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.entity.Player
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEvent
import eventDemo.shared.event.GameEventStream
import eventDemo.shared.event.GameState
import eventDemo.shared.event.buildStateFromEventStream
import eventDemo.app.GameState
import eventDemo.app.command.GameCommand
import eventDemo.app.command.GameCommandStream
import eventDemo.app.command.PlayCardCommand
import eventDemo.app.entity.Player
import eventDemo.app.event.CardIsPlayedEvent
import eventDemo.app.event.GameEvent
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
import io.ktor.websocket.Frame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,7 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
* Listen [GameCommand] on [GameCommandStream], check the validity and execute an action.
*
* This action can be executing an action and produce a new [GameEvent] after verification.
*/
@@ -64,10 +65,10 @@ class GameCommandHandler(
}
}
}
}
private fun GameState.commandCardCanBeExecuted(command: PlayCardCommand): Boolean =
canBePlayThisCard(
command.payload.player,
command.payload.card,
)
private fun GameState.commandCardCanBeExecuted(command: PlayCardCommand): Boolean =
canBePlayThisCard(
command.payload.player,
command.payload.card,
)
}

View File

@@ -1,14 +1,13 @@
package eventDemo.app.actions
package eventDemo.app
import eventDemo.app.event.GameEvent
import eventDemo.libs.event.EventBus
import eventDemo.shared.GameId
import eventDemo.shared.event.GameEvent
import eventDemo.shared.toFrame
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.runBlocking
class GameEventPlayerNotificationSubscriber(
class GameEventPlayerNotificationListener(
private val eventBus: EventBus<GameEvent, GameId>,
private val outgoing: SendChannel<Frame>,
) {

View File

@@ -1,13 +1,12 @@
package eventDemo.app.actions
package eventDemo.app
import eventDemo.app.event.GameEvent
import eventDemo.app.event.GameStartedEvent
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.libs.event.EventBus
import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId
import eventDemo.shared.event.GameEvent
import eventDemo.shared.event.GameStartedEvent
import eventDemo.shared.event.buildStateFromEventStream
class GameEventReactionSubscriber(
class GameEventReactionListener(
private val eventBus: EventBus<GameEvent, GameId>,
private val eventStream: EventStream<GameEvent, GameId>,
) {

View File

@@ -0,0 +1,15 @@
package eventDemo.app
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

View File

@@ -0,0 +1,145 @@
package eventDemo.app
import eventDemo.app.entity.Card
import eventDemo.app.entity.Deck
import eventDemo.app.entity.Player
import kotlinx.serialization.Serializable
@Serializable
data class GameState(
val gameId: GameId,
val players: Set<Player> = emptySet(),
val lastPlayer: Player? = null,
val lastCard: LastCard? = null,
val lastColor: Card.Color? = null,
val direction: Direction = Direction.CLOCKWISE,
val readyPlayers: List<Player> = emptyList(),
val deck: Deck = Deck(players.toList()),
val isStarted: Boolean = false,
) {
@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) }
}
fun canBePlayThisCard(
player: Player,
card: Card,
): Boolean {
if (!isReady) return false
val cardOnGame = lastCard?.card ?: return false
return when (cardOnGame) {
is Card.NumericCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.NumericCard -> card.number == cardOnGame.number || card.color == cardOnGame.color
is Card.ColorCard -> card.color == cardOnGame.color
}
}
is Card.ReverseCard -> {
when (card) {
is Card.ReverseCard -> true
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnGame.color
}
}
is Card.PassCard -> {
if (player.cardOnBoardIsForYou) {
false
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnGame.color
}
}
}
is Card.ChangeColorCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == lastColor
}
}
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 == cardOnGame.color
}
}
}
is Card.Plus4Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == lastColor
}
}
}
}
}
private val lastPlayerIndex: Int? get() {
val i = players.indexOf(lastPlayer)
return if (i == -1) {
null
} else {
i
}
}
private val nextPlayerIndex: Int get() {
val y =
if (direction == Direction.CLOCKWISE) {
+1
} else {
-1
}
return ((lastPlayerIndex ?: 0) + y) % players.size
}
val nextPlayer: Player = players.elementAt(nextPlayerIndex)
private val Player.currentIndex: Int get() = players.indexOf(this)
private 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 (lastCard == null) error("No card")
return this.playerDiffIndex(lastCard.player) == 1
}
}

View File

@@ -1,10 +1,11 @@
package eventDemo.app.actions.readLastPlayedCard
package eventDemo.app.actions
import eventDemo.app.GameId
import eventDemo.app.event.CardIsPlayedEvent
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.buildStateFromEventStream
import eventDemo.configuration.GameIdSerializer
import eventDemo.libs.event.readLastOf
import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.GameId
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEventStream
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
@@ -25,6 +26,12 @@ class Game(
class Card(
val game: Game,
)
@Serializable
@Resource("state")
class State(
val game: Game,
)
}
/**
@@ -36,10 +43,25 @@ fun Routing.readLastPlayedCard() {
/*
* Read the last played card on the game.
*/
get<Game.Card> { card ->
get<Game.Card> { body ->
eventStream
.readLastOf<CardIsPlayedEvent, _, _>(card.game.id)
.readLastOf<CardIsPlayedEvent, _, _>(body.game.id)
?.let { call.respond(it.card) }
?: call.response.status(HttpStatusCode.BadRequest)
}
}
/**
* API route to read the last card played.
*/
fun Routing.readGameState() {
val eventStream by inject<GameEventStream>()
/*
* Read the last played card on the game.
*/
get<Game.State> { body ->
val state = body.game.id.buildStateFromEventStream(eventStream)
call.respond(state)
}
}

View File

@@ -1,61 +0,0 @@
package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.send
import eventDemo.shared.GameId
import eventDemo.shared.command.GameCommandStreamInMemory
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Player
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.call
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.resources.post
import io.ktor.server.response.respondNullable
import io.ktor.server.routing.Routing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
@Resource("/game/{id}")
class GameRoute(
// @Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@Resource("card")
class Card(
val game: GameRoute,
)
}
/**
* API route to send a request to play card.
*/
fun Routing.playNewCard() {
val commandStream = GameCommandStreamInMemory()
authenticate {
/*
* A player request to play a new card.
*
* It always returns [HttpStatusCode.OK], but it is not mean that card is already played!
*/
post<GameRoute.Card> {
val card = call.receive<Card>()
val name = call.principal<Player>()!!
launch(Dispatchers.Default) {
commandStream.send(
PlayCardCommand(
it.game.id,
name,
card,
),
)
}
call.respondNullable<Any?>(HttpStatusCode.OK, null)
}
}
}

View File

@@ -0,0 +1,27 @@
package eventDemo.app.command
import eventDemo.libs.command.CommandStream
import eventDemo.libs.command.CommandStreamChannel
import eventDemo.libs.command.CommandStreamInMemory
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.serialization.json.Json
/**
* A stream to publish and read the game command.
*/
class GameCommandStreamInMemory : CommandStreamInMemory<GameCommand>()
/**
* A stream to publish and read the game command.
*/
class GameCommandStream(
incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
) : CommandStream<GameCommand> by CommandStreamChannel(
incoming,
outgoing,
{ Json.encodeToString(GameCommand.serializer(), it) },
{ Json.decodeFromString(GameCommand.serializer(), it) },
)

View File

@@ -1,10 +1,10 @@
package eventDemo.app.actions.playNewCard
package eventDemo.app.command
import eventDemo.app.GameId
import eventDemo.app.entity.Card
import eventDemo.app.entity.Player
import eventDemo.libs.command.Command
import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Player
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

View File

@@ -0,0 +1,109 @@
package eventDemo.app.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
}

View File

@@ -0,0 +1,53 @@
package eventDemo.app.entity
import kotlinx.serialization.Serializable
@Serializable
data class Deck(
val stack: Set<Card> = emptySet(),
val discard: Set<Card> = emptySet(),
val playersHands: List<PlayerHand> = emptyList(),
) {
constructor(players: List<Player>) : this(playersHands = players.map { PlayerHand(it) })
fun putOneCardOnDiscard(): Deck {
val takenCard = stack.first()
val newStack = stack.filterNot { it != takenCard }.toSet()
return copy(stack = newStack)
}
fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toSet()
return Pair(copy(stack = newStack), takenCards)
}
companion object {
fun initHands(
players: Set<Player>,
handSize: Int = 7,
): Deck {
val deck = new()
val playersHands = players.map { PlayerHand(it, deck.stack.take(handSize)) }
val allTakenCards = playersHands.flatMap { it.cards }
val newStack = deck.stack.filterNot { allTakenCards.contains(it) }.toSet()
return deck.copy(
stack = newStack,
playersHands = playersHands,
)
}
private fun new(): 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 {
(1..4).map { Card.Plus4Card() }
}.shuffled()
.toSet()
.let { Deck(it) }
}
}

View File

@@ -0,0 +1,36 @@
package eventDemo.app.entity
import eventDemo.configuration.PlayerIdSerializer
import eventDemo.configuration.UUIDSerializer
import eventDemo.libs.event.AggregateId
import io.ktor.server.auth.Principal
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()),
) : Principal {
constructor(id: String, name: String) : this(
name,
PlayerId(UUID.fromString(id)),
)
@JvmInline
value class PlayerId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
override fun toString(): String = id.toString()
}
}
@Serializable
data class PlayerHand(
val player: Player,
val cards: List<Card> = emptyList(),
) {
val count = lazy { cards.count() }
}

View File

@@ -0,0 +1,79 @@
package eventDemo.app.event
import eventDemo.app.GameId
import eventDemo.app.entity.Card
import eventDemo.app.entity.Deck
import eventDemo.app.entity.Player
import eventDemo.libs.event.Event
import kotlinx.serialization.Serializable
/**
* An [Event] of a Game.
*/
@Serializable
sealed interface GameEvent : Event<GameId> {
override val id: GameId
}
/**
* An [Event] to represent a played card.
*/
data class CardIsPlayedEvent(
override val id: GameId,
val card: Card,
val player: Player,
) : GameEvent
/**
* An [Event] to represent a new player joining the game.
*/
data class NewPlayerEvent(
override val id: GameId,
val player: Player,
) : GameEvent
/**
* This [Event] is sent when a player is ready.
*/
data class PlayerReadyEvent(
override val id: GameId,
val player: Player,
) : GameEvent
/**
* This [Event] is sent when a player is ready.
*/
data class GameStartedEvent(
override val id: GameId,
val firstPlayer: Player,
val deck: Deck,
) : GameEvent {
companion object {
fun new(
id: GameId,
players: Set<Player>,
): GameStartedEvent =
GameStartedEvent(
id = id,
firstPlayer = players.random(),
deck = Deck.initHands(players).putOneCardOnDiscard(),
)
}
}
/**
* This [Event] is sent when a player can play.
*/
data class PlayerHavePassEvent(
override val id: GameId,
val player: Player,
) : GameEvent
/**
* This [Event] is sent when a player chose a color.
*/
data class PlayerChoseColorEvent(
override val id: GameId,
val player: Player,
val color: Card.Color,
) : GameEvent

View File

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

View File

@@ -0,0 +1,18 @@
package eventDemo.app.event
import eventDemo.app.GameId
import eventDemo.libs.event.EventBus
import eventDemo.libs.event.EventStream
/**
* A stream to publish and read the played card event.
*/
class GameEventStream(
private val eventBus: EventBus<GameEvent, GameId>,
private val m: EventStream<GameEvent, GameId>,
) : EventStream<GameEvent, GameId> by m {
override fun publish(event: GameEvent) {
m.publish(event)
eventBus.publish(event)
}
}

View File

@@ -0,0 +1,72 @@
package eventDemo.app.event
import eventDemo.app.GameId
import eventDemo.app.GameState
import eventDemo.app.entity.Card
import eventDemo.libs.event.EventStream
fun GameId.buildStateFromEventStream(eventStream: EventStream<GameEvent, GameId>): GameState =
buildStateFromEvents(
eventStream.readAll(this),
)
private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
events.fold(GameState(this)) { state: GameState, event: GameEvent ->
when (event) {
is CardIsPlayedEvent -> {
val direction =
when (event.card) {
is Card.ReverseCard -> state.direction.revert()
else -> state.direction
}
val color =
when (event.card) {
is Card.ColorCard -> event.card.color
else -> state.lastColor
}
state.copy(
lastPlayer = event.player,
direction = direction,
lastColor = color,
)
}
is NewPlayerEvent -> {
if (state.isReady) error("The game is already started")
state.copy(
players = state.players + event.player,
)
}
is PlayerReadyEvent -> {
state.copy(
readyPlayers = state.readyPlayers + event.player,
)
}
is PlayerHavePassEvent -> {
state.copy(
lastPlayer = event.player,
)
}
is PlayerChoseColorEvent -> {
state.copy(
lastColor = event.color,
)
}
is GameStartedEvent -> {
state.copy(
lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color,
lastCard = eventDemo.app.GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
lastPlayer = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
}
}