Create GameStateRepository

Use GameState.apply() to build Projection
Create GameEventHandler
Add PlayerWinEvent
This commit is contained in:
2025-03-09 03:43:31 +01:00
parent 3080e515d6
commit 19e425d684
22 changed files with 371 additions and 81 deletions

View File

@@ -0,0 +1,27 @@
package eventDemo.app.event
import eventDemo.app.event.event.GameEvent
/**
* A stream to publish and read the played card event.
*/
class GameEventHandler(
private val eventBus: GameEventBus,
private val eventStream: GameEventStream,
) {
private val projectionsBuilders: MutableList<(GameEvent) -> Unit> = mutableListOf()
fun registerProjectionBuilder(builder: GameProjectionBuilder) {
projectionsBuilders.add(builder)
}
fun handle(vararg events: GameEvent) {
events.forEach { event ->
eventStream.publish(event)
projectionsBuilders.forEach { it(event) }
eventBus.publish(event)
}
}
}
typealias GameProjectionBuilder = (GameEvent) -> Unit

View File

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

View File

@@ -17,14 +17,15 @@ data class GameStartedEvent(
fun new(
id: GameId,
players: Set<Player>,
shuffleIsDisabled: Boolean = isDisabled,
): GameStartedEvent =
GameStartedEvent(
gameId = id,
firstPlayer = if (isDisabled) players.first() else players.random(),
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck =
Deck
.newWithoutPlayers()
.let { if (isDisabled) it else it.shuffle() }
.let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players)
.placeFirstCardOnDiscard(),
)

View File

@@ -0,0 +1,12 @@
package eventDemo.app.event.event
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
/**
* This [GameEvent] is sent when a player is ready.
*/
data class PlayerWinEvent(
override val gameId: GameId,
val player: Player,
) : GameEvent

View File

@@ -0,0 +1,165 @@
package eventDemo.app.event.projection
import eventDemo.app.entity.Card
import eventDemo.app.entity.Deck
import eventDemo.app.entity.GameId
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: Set<Player> = emptySet(),
val deck: Deck = Deck(players),
val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(),
) {
@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 lastPlayerIndex: Int? get() {
val i = players.indexOf(lastPlayer)
return if (i == -1) {
null
} else {
i
}
}
private val nextPlayerIndex: Int get() {
if (players.size == 0) return 0
val y =
if (direction == Direction.CLOCKWISE) {
+1
} else {
-1
}
return ((lastPlayerIndex ?: 0) + y) % players.size
}
val nextPlayer: Player? by lazy {
if (players.isEmpty()) {
null
} else {
players.elementAt(nextPlayerIndex)
}
}
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 (lastCard == null) error("No card")
return this.playerDiffIndex(lastCard.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 = lastCard?.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 == 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 == 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 == lastColor
}
}
}
}
}
}

View File

@@ -1,8 +1,8 @@
package eventDemo.app.event
package eventDemo.app.event.projection
import eventDemo.app.GameState
import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.CardIsPlayedEvent
import eventDemo.app.event.event.GameEvent
import eventDemo.app.event.event.GameStartedEvent
@@ -10,19 +10,33 @@ import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerChoseColorEvent
import eventDemo.app.event.event.PlayerHavePassEvent
import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.event.PlayerWinEvent
fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState =
buildStateFromEvents(
eventStream.readAll(this),
)
/**
* Build the state to the specific event
*/
fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState =
gameId.buildStateFromEvents(
eventStream.readAll(gameId).takeWhile { it != this } + this,
)
private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
events.fold(GameState(this)) { state: GameState, event: GameEvent ->
events.fold(GameState(this)) { state, event ->
state.apply(event)
}
fun List<GameEvent>.buildStateFromEvents(): GameState =
fold(GameState(this.first().gameId)) { state, event ->
state.apply(event)
}
fun GameState.apply(event: GameEvent): GameState =
let { state ->
when (event) {
is CardIsPlayedEvent -> {
val direction =
@@ -83,5 +97,11 @@ private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
isStarted = true,
)
}
is PlayerWinEvent -> {
copy(
playerWins = playerWins + event.player,
)
}
}
}

View File

@@ -0,0 +1,34 @@
package eventDemo.app.event.projection
import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream
import java.util.concurrent.ConcurrentHashMap
class GameStateRepository(
private val eventStream: GameEventStream,
eventHandler: GameEventHandler,
) {
private val projections: ConcurrentHashMap<GameId, GameState> = ConcurrentHashMap()
init {
eventHandler.registerProjectionBuilder { event ->
val projection = projections[event.gameId]
if (projection == null) {
event.gameId
.buildStateFromEventStream(eventStream)
.update()
} else {
projection
.apply(event)
.let { projections.put(it.gameId, it) }
}
}
}
fun get(gameId: GameId): GameState = gameId.buildStateFromEventStream(eventStream)
private fun GameState.update() {
projections[gameId] = this
}
}