create bus and subscriber

This commit is contained in:
2025-03-04 23:02:07 +01:00
parent a6847353b2
commit f3ca94c97e
36 changed files with 885 additions and 234 deletions

View File

@@ -1,19 +1,29 @@
package eventDemo
import eventDemo.plugins.configureHTTP
import eventDemo.app.actions.GameEventReactionSubscriber
import eventDemo.plugins.configureHttp
import eventDemo.plugins.configureHttpRouting
import eventDemo.plugins.configureKoin
import eventDemo.plugins.configureRouting
import eventDemo.plugins.configureSecurity
import eventDemo.plugins.configureSerialization
import eventDemo.plugins.configureSockets
import eventDemo.plugins.configureWebSocketsGameRoute
import io.ktor.server.application.Application
import org.koin.ktor.ext.get
fun Application.configure() {
configureSecurity()
configureSerialization()
configureSockets()
configureHTTP()
configureRouting()
configureKoin()
configureCommandHandler()
configureSecurity()
configureSerialization()
configureSockets()
configureWebSocketsGameRoute(get(), get())
configureHttp()
configureHttpRouting()
GameEventReactionSubscriber(get(), get())
.init()
}

View File

@@ -1,14 +0,0 @@
package eventDemo
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
import io.ktor.server.application.Application
import org.koin.java.KoinJavaComponent.getKoin
/**
* Configure the command handler for the PlayCard.
*/
fun Application.configureCommandHandler() {
getKoin()
.get<PlayCardCommandHandler>()
.init()
}

View File

@@ -0,0 +1,73 @@
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 io.ktor.websocket.Frame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
*
* This action can be executing an action and produce a new [GameEvent] after verification.
*/
class GameCommandHandler(
private val eventStream: GameEventStream,
incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
) {
private val commandStream = GameCommandStream(incoming, outgoing)
private val playerNotifier = outgoing
/**
* Init the handler
*/
fun init(player: Player) {
CoroutineScope(Dispatchers.IO).launch {
commandStream.process {
if (it.payload.player.id != player.id) {
nack()
}
when (it) {
is PlayCardCommand -> {
// Check the command can be executed
val canBeExecuted =
it.payload.gameId
.buildStateFromEventStream(eventStream)
.commandCardCanBeExecuted(it)
if (canBeExecuted) {
eventStream.publish(
CardIsPlayedEvent(
it.payload.gameId,
it.payload.card,
it.payload.player,
),
)
} else {
runBlocking {
playerNotifier.send(Frame.Text("Command cannot be executed"))
}
}
}
}
}
}
}
}
private fun GameState.commandCardCanBeExecuted(command: PlayCardCommand): Boolean =
canBePlayThisCard(
command.payload.player,
command.payload.card,
)

View File

@@ -0,0 +1,22 @@
package eventDemo.app.actions
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(
private val eventBus: EventBus<GameEvent, GameId>,
private val outgoing: SendChannel<Frame>,
) {
fun init() {
eventBus.subscribe { event: GameEvent ->
runBlocking {
outgoing.send(event.toFrame())
}
}
}
}

View File

@@ -0,0 +1,27 @@
package eventDemo.app.actions
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(
private val eventBus: EventBus<GameEvent, GameId>,
private val eventStream: EventStream<GameEvent, GameId>,
) {
fun init() {
eventBus.subscribe { event: GameEvent ->
val state = event.id.buildStateFromEventStream(eventStream)
if (state.isReady) {
eventStream.publish(
GameStartedEvent.new(
state.gameId,
state.players,
),
)
}
}
}
}

View File

@@ -1,14 +1,15 @@
package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.send
import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.GameId
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.command.GameCommandStreamInMemory
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game
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
@@ -16,12 +17,11 @@ import io.ktor.server.routing.Routing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import org.koin.ktor.ext.inject
@Serializable
@Resource("/game/{id}")
class GameRoute(
@Serializable(with = GameIdSerializer::class)
// @Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@@ -35,19 +35,27 @@ class GameRoute(
* API route to send a request to play card.
*/
fun Routing.playNewCard() {
val commandStream by inject<GameCommandStream>()
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,
),
)
}
/*
* 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>()
launch(Dispatchers.Default) {
commandStream.send(PlayCardCommand(Game(it.game.id), card))
call.respondNullable<Any?>(HttpStatusCode.OK, null)
}
call.respondNullable<Any?>(HttpStatusCode.OK, null)
}
}

View File

@@ -2,8 +2,9 @@ package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.Command
import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game
import eventDemo.shared.entity.Player
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -13,19 +14,32 @@ import kotlinx.serialization.Serializable
@Serializable
@SerialName("PlayCard")
data class PlayCardCommand(
val payload: Payload,
) : Command {
override val payload: Payload,
) : GameCommand {
constructor(
game: Game,
gameId: GameId,
player: Player,
card: Card,
) : this(Payload(game, card))
) : this(Payload(gameId, player, card))
override val name: String = "PlayCard"
override val id: CommandId = CommandId()
@Serializable
data class Payload(
val game: Game,
override val gameId: GameId,
override val player: Player,
val card: Card,
)
) : GameCommand.Payload
}
@Serializable
sealed interface GameCommand : Command {
val payload: Payload
@Serializable
sealed interface Payload {
val gameId: GameId
val player: Player
}
}

View File

@@ -1,30 +0,0 @@
package eventDemo.app.actions.playNewCard
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEventStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
*
* This action produces a new [CardIsPlayedEvent]
*/
class PlayCardCommandHandler(
private val commandStream: GameCommandStream,
private val eventStream: GameEventStream,
) {
/**
* Init the handler
*/
fun init() {
CoroutineScope(Dispatchers.IO).launch {
commandStream.process {
// TODO check the command can be executed
eventStream.publish(CardIsPlayedEvent(it.payload.game.id, it.payload.card))
}
}
}
}

View File

@@ -0,0 +1,97 @@
package eventDemo.libs.command
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.runBlocking
import kotlin.reflect.KClass
/**
* Manage [Command]'s with kotlin Channel
*/
class CommandStreamChannel<C : Command>(
private val incoming: ReceiveChannel<Frame>,
private val outgoing: SendChannel<Frame>,
private val serializer: (C) -> String,
private val deserializer: (String) -> C,
) : CommandStream<C> {
private val logger = KotlinLogging.logger {}
private val failedCommand = mutableListOf<C>()
/**
* Send a new [Command] to the queue.
*/
override suspend fun send(
type: KClass<C>,
command: C,
) {
logger.atInfo {
message = "Command published: $command"
payload = mapOf("command" to command)
}
outgoing.send(Frame.Text(serializer(command)))
}
override suspend fun process(action: CommandStream.ComputeStatus.(C) -> Unit) {
// incoming.consumeEach { commandAsFrame ->
// if (commandAsFrame is Frame.Text) {
// compute(deserializer(commandAsFrame.readText()), action)
// }
// }
for (command in incoming) {
if (command is Frame.Text) {
compute(deserializer(command.readText()), action)
}
}
}
private fun compute(
command: C,
action: CommandStream.ComputeStatus.(C) -> Unit,
) {
val status =
object : CommandStream.ComputeStatus {
var isSet: Boolean = false
override fun ack() {
if (!isSet) markAsSuccess(command) else error("Already NACK")
isSet = true
}
override fun nack() {
if (!isSet) markAsFailed(command) else error("Already ACK")
isSet = true
}
}
if (runCatching { status.action(command) }.isFailure) {
markAsFailed(command)
} else if (!status.isSet) {
status.ack()
}
}
private fun markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack : $command"
payload = mapOf("command" to command)
}
runBlocking {
outgoing.send(Frame.Text("Command executed successfully"))
}
}
private fun markAsFailed(command: C) {
failedCommand.add(command)
logger.atWarn {
message = "Compute command FAILED and it put it ot the top of the stack : $command"
payload = mapOf("command" to command)
}
runBlocking {
outgoing.send(Frame.Text("Command execution failed"))
}
}
}

View File

@@ -44,19 +44,20 @@ abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
command: C,
action: CommandBlock<C>,
) {
val status = object : CommandStream.ComputeStatus {
var isSet: Boolean = false
val status =
object : CommandStream.ComputeStatus {
var isSet: Boolean = false
override fun ack() {
if (!isSet) markAsSuccess(command) else error("Already NACK")
isSet = true
}
override fun ack() {
if (!isSet) markAsSuccess(command) else error("Already NACK")
isSet = true
}
override fun nack() {
if (!isSet) markAsFailed(command) else error("Already ACK")
isSet = true
override fun nack() {
if (!isSet) markAsFailed(command) else error("Already ACK")
isSet = true
}
}
}
if (runCatching { status.action(command) }.isFailure) {
markAsFailed(command)

View File

@@ -0,0 +1,7 @@
package eventDemo.libs.event
interface EventBus<E : Event<ID>, ID : AggregateId> {
fun publish(event: E)
fun subscribe(block: (E) -> Unit)
}

View File

@@ -0,0 +1,15 @@
package eventDemo.libs.event
class EventBusInMemory<E : Event<ID>, ID : AggregateId> : EventBus<E, ID> {
private val subscribers: MutableList<(E) -> Unit> = mutableListOf()
override fun publish(event: E) {
subscribers.forEach {
it(event)
}
}
override fun subscribe(block: (E) -> Unit) {
subscribers.add(block)
}
}

View File

@@ -1,6 +1,5 @@
package eventDemo.libs.event
import kotlinx.coroutines.flow.Flow
import kotlin.reflect.KClass
/**
@@ -22,6 +21,6 @@ interface EventStream<E : Event<ID>, ID : AggregateId> {
eventType: KClass<out R>,
): E?
/** Reads all events associated with a given aggregate ID as a Flow (asynchronous stream) */
fun readAll(aggregateId: ID): Flow<E>
/** Reads all events associated with a given aggregate ID */
fun readAll(aggregateId: ID): List<E>
}

View File

@@ -1,8 +1,6 @@
package eventDemo.libs.event
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.reflect.KClass
/**
@@ -10,14 +8,12 @@ import kotlin.reflect.KClass
*
* All methods are implemented.
*/
abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>(
private val eventType: Class<E>,
) : EventStream<E, ID> {
class EventStreamInMemory<E : Event<ID>, ID : AggregateId> : EventStream<E, ID> {
private val logger = KotlinLogging.logger {}
private val eventBus: MutableList<E> = mutableListOf()
private val events: MutableList<E> = mutableListOf()
override fun publish(event: E) {
eventBus.add(event)
events.add(event)
logger.atInfo {
message = "Event published: $event"
payload = mapOf("event" to event)
@@ -28,20 +24,17 @@ abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>(
events.forEach { publish(it) }
}
override fun readLast(aggregateId: ID): E? = eventBus.lastOrNull()
override fun readLast(aggregateId: ID): E? = events.lastOrNull()
override fun <R : E> readLastOf(
aggregateId: ID,
eventType: KClass<out R>,
): R? =
eventBus
events
.filterIsInstance(eventType.java)
.lastOrNull { it.id == aggregateId }
override fun readAll(aggregateId: ID): Flow<E> =
flow {
eventBus.forEach { emit(it) }
}
override fun readAll(aggregateId: ID): List<E> = events
}
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? =

View File

@@ -7,7 +7,7 @@ import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS
fun Application.configureHTTP() {
fun Application.configureHttp() {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)

View File

@@ -1,11 +1,11 @@
package eventDemo.plugins
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
import eventDemo.shared.command.GameCommandStream
import eventDemo.libs.event.EventBusInMemory
import eventDemo.libs.event.EventStreamInMemory
import eventDemo.shared.event.GameEventBus
import eventDemo.shared.event.GameEventStream
import io.ktor.server.application.Application
import io.ktor.server.application.install
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
@@ -13,13 +13,16 @@ import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
install(Koin) {
slf4jLogger()
modules(appModule)
modules(appKoinModule)
}
}
val appModule =
val appKoinModule =
module {
singleOf(::GameEventStream)
singleOf(::GameCommandStream)
singleOf(::PlayCardCommandHandler)
single {
GameEventStream(get(), EventStreamInMemory())
}
single {
GameEventBus(EventBusInMemory())
}
}

View File

@@ -11,7 +11,7 @@ import io.ktor.server.resources.Resources
import io.ktor.server.response.respondText
import io.ktor.server.routing.routing
fun Application.configureRouting() {
fun Application.configureHttpRouting() {
install(AutoHeadResponse)
install(Resources)
install(StatusPages) {

View File

@@ -2,25 +2,22 @@ package eventDemo.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.authentication
import io.ktor.server.auth.basic
import io.ktor.server.auth.form
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.principal
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.util.Date
fun Application.configureSecurity() {
// Please read the jwt property from the config file if you are using EngineMain
val jwtAudience = "jwt-audience"
val jwtDomain = "https://jwt-provider-domain/"
val jwtRealm = "ktor sample app"
// TODO: read the jwt property from the config file
val jwtRealm = "Play card game"
val jwtIssuer = "PlayCardGame"
val jwtSecret = "secret"
authentication {
jwt {
@@ -28,47 +25,35 @@ fun Application.configureSecurity() {
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtDomain)
.withIssuer(jwtIssuer)
.build(),
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
}
}
}
authentication {
basic(name = "myauth1") {
realm = "Ktor Server"
validate { credentials ->
if (credentials.name == credentials.password) {
UserIdPrincipal(credentials.name)
if (credential.payload.getClaim("username").asString() != "") {
JWTPrincipal(credential.payload)
} else {
null
}
}
}
form(name = "myauth2") {
userParamName = "user"
passwordParamName = "password"
challenge {
//
challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
}
routing {
authenticate("myauth1") {
get("/protected/route/basic") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name}")
}
}
authenticate("myauth2") {
get("/protected/route/form") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name}")
}
post("login/{username}") {
val username = call.parameters["username"]
val token =
JWT
.create()
.withIssuer(jwtIssuer)
.withClaim("username", username)
.withExpiresAt(Date(System.currentTimeMillis() + 60000))
.sign(Algorithm.HMAC256(jwtSecret))
call.respond(hashMapOf("token" to token))
}
}
}

View File

@@ -2,6 +2,7 @@ package eventDemo.plugins
import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId
import eventDemo.shared.entity.Player.PlayerId
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
@@ -42,6 +43,19 @@ object CommandIdSerializer : KSerializer<CommandId> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING)
}
object PlayerIdSerializer : KSerializer<PlayerId> {
override fun deserialize(decoder: Decoder): PlayerId = PlayerId(UUID.fromString(decoder.decodeString()))
override fun serialize(
encoder: Encoder,
value: PlayerId,
) {
encoder.encodeString(value.id.toString())
}
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING)
}
object GameIdSerializer : KSerializer<GameId> {
override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString()))

View File

@@ -1,16 +1,21 @@
package eventDemo.plugins
import eventDemo.app.actions.GameCommandHandler
import eventDemo.app.actions.GameEventPlayerNotificationSubscriber
import eventDemo.shared.entity.Player
import eventDemo.shared.event.GameEventBus
import eventDemo.shared.event.GameEventStream
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import java.time.Duration
fun Application.configureSockets() {
@@ -20,18 +25,26 @@ fun Application.configureSockets() {
maxFrameSize = Long.MAX_VALUE
masking = false
}
}
fun Application.configureWebSocketsGameRoute(
eventStream: GameEventStream,
eventBus: GameEventBus,
) {
routing {
webSocket("/ws") {
// websocketSession
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
outgoing.send(Frame.Text("YOU SAID: $text"))
if (text.equals("bye", ignoreCase = true)) {
close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
}
}
authenticate {
webSocket("/game") {
GameCommandHandler(eventStream, incoming, outgoing).init(call.getPlayer())
GameEventPlayerNotificationSubscriber(eventBus, outgoing).init()
}
}
}
}
fun ApplicationCall.getPlayer() =
principal<JWTPrincipal>()!!.run {
Player(
id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(),
)
}

View File

@@ -0,0 +1,15 @@
package eventDemo.shared
import eventDemo.app.actions.playNewCard.GameCommand
import eventDemo.shared.event.GameEvent
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.serialization.json.Json
fun Frame.Text.toEvent(): GameEvent = Json.decodeFromString(GameEvent.serializer(), readText())
fun GameEvent.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameEvent.serializer(), this))
fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText())
fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this))

View File

@@ -2,19 +2,14 @@ package eventDemo.shared
import eventDemo.libs.event.AggregateId
import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.entity.Game
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [AggregateId] for the [Game].
* An [AggregateId] for a game.
*/
@JvmInline
@Serializable(with = GameIdSerializer::class)
value class GameId(
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
constructor(id: String) : this(UUID.fromString(id))
override fun toString(): String = id.toString()
}
) : AggregateId

View File

@@ -0,0 +1,143 @@
package eventDemo.shared.event
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Deck
import eventDemo.shared.entity.Player
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,
) {
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,9 +1,28 @@
package eventDemo.shared.command
import eventDemo.app.actions.playNewCard.PlayCardCommand
import eventDemo.app.actions.playNewCard.GameCommand
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 played card command.
* A stream to publish and read the game command.
*/
class GameCommandStream : CommandStreamInMemory<PlayCardCommand>()
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,13 +1,17 @@
package eventDemo.shared.entity
import eventDemo.plugins.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
*/
@@ -19,6 +23,10 @@ sealed interface Card {
Green,
}
sealed interface ColorCard : Card {
val color: Color
}
/**
* A play card with color and number
*/
@@ -26,8 +34,11 @@ sealed interface Card {
@SerialName("Simple")
data class NumericCard(
val number: Int,
val color: Color,
) : Card
override val color: Color,
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Card,
ColorCard
sealed interface Special : Card
@@ -37,8 +48,13 @@ sealed interface Card {
@Serializable
@SerialName("Reverse")
data class ReverseCard(
val color: Color,
) : Special
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.
@@ -46,8 +62,12 @@ sealed interface Card {
@Serializable
@SerialName("Pass")
data class PassCard(
val color: Color,
) : Special
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.
@@ -55,24 +75,35 @@ sealed interface Card {
@Serializable
@SerialName("Plus2")
data class Plus2Card(
val color: Color,
) : Special
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")
data class Plus4Card(
val nextColor: Color,
) : Special
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")
data class ChangeColorCard(
val nextColor: Color,
) : Special
class ChangeColorCard(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
AllColorCard
}

View File

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

@@ -1,16 +0,0 @@
package eventDemo.shared.entity
import eventDemo.shared.GameId
import kotlinx.serialization.Serializable
/**
* Represent a Game
*/
@Serializable
data class Game(
val id: GameId,
) {
companion object {
fun new(): Game = Game(GameId())
}
}

View File

@@ -0,0 +1,36 @@
package eventDemo.shared.entity
import eventDemo.libs.event.AggregateId
import eventDemo.plugins.PlayerIdSerializer
import eventDemo.plugins.UUIDSerializer
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

@@ -3,11 +3,14 @@ package eventDemo.shared.event
import eventDemo.libs.event.Event
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game
import eventDemo.shared.entity.Deck
import eventDemo.shared.entity.Player
import kotlinx.serialization.Serializable
/**
* An [Event] of a [Game].
* An [Event] of a Game.
*/
@Serializable
sealed interface GameEvent : Event<GameId> {
override val id: GameId
}
@@ -18,4 +21,59 @@ sealed interface GameEvent : Event<GameId> {
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.shared.event
import eventDemo.libs.event.EventBus
import eventDemo.shared.GameId
class GameEventBus(
bus: EventBus<GameEvent, GameId>,
) : EventBus<GameEvent, GameId> by bus

View File

@@ -1,9 +1,18 @@
package eventDemo.shared.event
import eventDemo.libs.event.EventStreamInMemory
import eventDemo.libs.event.EventBus
import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId
/**
* A stream to publish and read the played card event.
*/
class GameEventStream : EventStreamInMemory<GameEvent, GameId>(GameEvent::class.java)
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,71 @@
package eventDemo.shared.event
import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
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 = GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
lastPlayer = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
}
}