create bus and subscriber
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
ktlint_code_style = ktlint_official
|
ktlint_code_style = ktlint_official
|
||||||
ktlint_standard = enabled
|
ktlint_standard = enabled
|
||||||
ktlint_experimental = enabled
|
ktlint_experimental = enabled
|
||||||
ktlint_standard_string-template-indent = disabled
|
ktlint_standard_string-template-indent = enabled
|
||||||
ktlint_standard_multiline-expression-wrapping = disabled
|
ktlint_standard_multiline-expression-wrapping = enabled
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="ApplicationKt" type="KtorApplicationConfigurationType" factoryName="Ktor" nameIsGenerated="true">
|
|
||||||
<module name="event-demo.main" />
|
|
||||||
<option name="alternativeJrePath" />
|
|
||||||
<option name="alternativeJrePathEnabled" value="false" />
|
|
||||||
<option name="includeProvidedScope" value="true" />
|
|
||||||
<option name="mainClass" value="eventDemo.ApplicationKt" />
|
|
||||||
<option name="passParentEnvs" value="true" />
|
|
||||||
<option name="programParameters" value="" />
|
|
||||||
<option name="shortenCommandLine" value="NONE" />
|
|
||||||
<option name="vmParameters" value="-Dio.ktor.development=true" />
|
|
||||||
<option name="workingDirectory" value="$PROJECT_DIR$" />
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -13,7 +13,7 @@ plugins {
|
|||||||
kotlin("jvm") version "2.1.10"
|
kotlin("jvm") version "2.1.10"
|
||||||
id("io.ktor.plugin") version "2.3.8"
|
id("io.ktor.plugin") version "2.3.8"
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
|
id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "io.github.flecomte"
|
group = "io.github.flecomte"
|
||||||
@@ -26,6 +26,11 @@ application {
|
|||||||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
|
||||||
|
version.set("1.5.0")
|
||||||
|
enableExperimentalRules.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
package eventDemo
|
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.configureKoin
|
||||||
import eventDemo.plugins.configureRouting
|
|
||||||
import eventDemo.plugins.configureSecurity
|
import eventDemo.plugins.configureSecurity
|
||||||
import eventDemo.plugins.configureSerialization
|
import eventDemo.plugins.configureSerialization
|
||||||
import eventDemo.plugins.configureSockets
|
import eventDemo.plugins.configureSockets
|
||||||
|
import eventDemo.plugins.configureWebSocketsGameRoute
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
|
import org.koin.ktor.ext.get
|
||||||
|
|
||||||
fun Application.configure() {
|
fun Application.configure() {
|
||||||
configureSecurity()
|
|
||||||
configureSerialization()
|
|
||||||
configureSockets()
|
|
||||||
configureHTTP()
|
|
||||||
configureRouting()
|
|
||||||
configureKoin()
|
configureKoin()
|
||||||
configureCommandHandler()
|
|
||||||
|
configureSecurity()
|
||||||
|
|
||||||
|
configureSerialization()
|
||||||
|
|
||||||
|
configureSockets()
|
||||||
|
configureWebSocketsGameRoute(get(), get())
|
||||||
|
|
||||||
|
configureHttp()
|
||||||
|
configureHttpRouting()
|
||||||
|
|
||||||
|
GameEventReactionSubscriber(get(), get())
|
||||||
|
.init()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
73
src/main/kotlin/eventDemo/app/actions/GameCommandHandler.kt
Normal file
73
src/main/kotlin/eventDemo/app/actions/GameCommandHandler.kt
Normal 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,
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
package eventDemo.app.actions.playNewCard
|
package eventDemo.app.actions.playNewCard
|
||||||
|
|
||||||
import eventDemo.libs.command.send
|
import eventDemo.libs.command.send
|
||||||
import eventDemo.plugins.GameIdSerializer
|
|
||||||
import eventDemo.shared.GameId
|
import eventDemo.shared.GameId
|
||||||
import eventDemo.shared.command.GameCommandStream
|
import eventDemo.shared.command.GameCommandStreamInMemory
|
||||||
import eventDemo.shared.entity.Card
|
import eventDemo.shared.entity.Card
|
||||||
import eventDemo.shared.entity.Game
|
import eventDemo.shared.entity.Player
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.resources.Resource
|
import io.ktor.resources.Resource
|
||||||
import io.ktor.server.application.call
|
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.request.receive
|
||||||
import io.ktor.server.resources.post
|
import io.ktor.server.resources.post
|
||||||
import io.ktor.server.response.respondNullable
|
import io.ktor.server.response.respondNullable
|
||||||
@@ -16,12 +17,11 @@ import io.ktor.server.routing.Routing
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.koin.ktor.ext.inject
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@Resource("/game/{id}")
|
@Resource("/game/{id}")
|
||||||
class GameRoute(
|
class GameRoute(
|
||||||
@Serializable(with = GameIdSerializer::class)
|
// @Serializable(with = GameIdSerializer::class)
|
||||||
val id: GameId,
|
val id: GameId,
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -35,19 +35,27 @@ class GameRoute(
|
|||||||
* API route to send a request to play card.
|
* API route to send a request to play card.
|
||||||
*/
|
*/
|
||||||
fun Routing.playNewCard() {
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
call.respondNullable<Any?>(HttpStatusCode.OK, null)
|
||||||
* 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package eventDemo.app.actions.playNewCard
|
|||||||
|
|
||||||
import eventDemo.libs.command.Command
|
import eventDemo.libs.command.Command
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
|
import eventDemo.shared.GameId
|
||||||
import eventDemo.shared.entity.Card
|
import eventDemo.shared.entity.Card
|
||||||
import eventDemo.shared.entity.Game
|
import eventDemo.shared.entity.Player
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -13,19 +14,32 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("PlayCard")
|
@SerialName("PlayCard")
|
||||||
data class PlayCardCommand(
|
data class PlayCardCommand(
|
||||||
val payload: Payload,
|
override val payload: Payload,
|
||||||
) : Command {
|
) : GameCommand {
|
||||||
constructor(
|
constructor(
|
||||||
game: Game,
|
gameId: GameId,
|
||||||
|
player: Player,
|
||||||
card: Card,
|
card: Card,
|
||||||
) : this(Payload(game, card))
|
) : this(Payload(gameId, player, card))
|
||||||
|
|
||||||
override val name: String = "PlayCard"
|
override val name: String = "PlayCard"
|
||||||
override val id: CommandId = CommandId()
|
override val id: CommandId = CommandId()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Payload(
|
data class Payload(
|
||||||
val game: Game,
|
override val gameId: GameId,
|
||||||
|
override val player: Player,
|
||||||
val card: Card,
|
val card: Card,
|
||||||
)
|
) : GameCommand.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface GameCommand : Command {
|
||||||
|
val payload: Payload
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface Payload {
|
||||||
|
val gameId: GameId
|
||||||
|
val player: Player
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,19 +44,20 @@ abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
|
|||||||
command: C,
|
command: C,
|
||||||
action: CommandBlock<C>,
|
action: CommandBlock<C>,
|
||||||
) {
|
) {
|
||||||
val status = object : CommandStream.ComputeStatus {
|
val status =
|
||||||
var isSet: Boolean = false
|
object : CommandStream.ComputeStatus {
|
||||||
|
var isSet: Boolean = false
|
||||||
|
|
||||||
override fun ack() {
|
override fun ack() {
|
||||||
if (!isSet) markAsSuccess(command) else error("Already NACK")
|
if (!isSet) markAsSuccess(command) else error("Already NACK")
|
||||||
isSet = true
|
isSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun nack() {
|
override fun nack() {
|
||||||
if (!isSet) markAsFailed(command) else error("Already ACK")
|
if (!isSet) markAsFailed(command) else error("Already ACK")
|
||||||
isSet = true
|
isSet = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (runCatching { status.action(command) }.isFailure) {
|
if (runCatching { status.action(command) }.isFailure) {
|
||||||
markAsFailed(command)
|
markAsFailed(command)
|
||||||
|
|||||||
7
src/main/kotlin/eventDemo/libs/event/EventBus.kt
Normal file
7
src/main/kotlin/eventDemo/libs/event/EventBus.kt
Normal 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)
|
||||||
|
}
|
||||||
15
src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt
Normal file
15
src/main/kotlin/eventDemo/libs/event/EventBusInMemory.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package eventDemo.libs.event
|
package eventDemo.libs.event
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +21,6 @@ interface EventStream<E : Event<ID>, ID : AggregateId> {
|
|||||||
eventType: KClass<out R>,
|
eventType: KClass<out R>,
|
||||||
): E?
|
): E?
|
||||||
|
|
||||||
/** Reads all events associated with a given aggregate ID as a Flow (asynchronous stream) */
|
/** Reads all events associated with a given aggregate ID */
|
||||||
fun readAll(aggregateId: ID): Flow<E>
|
fun readAll(aggregateId: ID): List<E>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package eventDemo.libs.event
|
package eventDemo.libs.event
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,14 +8,12 @@ import kotlin.reflect.KClass
|
|||||||
*
|
*
|
||||||
* All methods are implemented.
|
* All methods are implemented.
|
||||||
*/
|
*/
|
||||||
abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>(
|
class EventStreamInMemory<E : Event<ID>, ID : AggregateId> : EventStream<E, ID> {
|
||||||
private val eventType: Class<E>,
|
|
||||||
) : EventStream<E, ID> {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val eventBus: MutableList<E> = mutableListOf()
|
private val events: MutableList<E> = mutableListOf()
|
||||||
|
|
||||||
override fun publish(event: E) {
|
override fun publish(event: E) {
|
||||||
eventBus.add(event)
|
events.add(event)
|
||||||
logger.atInfo {
|
logger.atInfo {
|
||||||
message = "Event published: $event"
|
message = "Event published: $event"
|
||||||
payload = mapOf("event" to event)
|
payload = mapOf("event" to event)
|
||||||
@@ -28,20 +24,17 @@ abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>(
|
|||||||
events.forEach { publish(it) }
|
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(
|
override fun <R : E> readLastOf(
|
||||||
aggregateId: ID,
|
aggregateId: ID,
|
||||||
eventType: KClass<out R>,
|
eventType: KClass<out R>,
|
||||||
): R? =
|
): R? =
|
||||||
eventBus
|
events
|
||||||
.filterIsInstance(eventType.java)
|
.filterIsInstance(eventType.java)
|
||||||
.lastOrNull { it.id == aggregateId }
|
.lastOrNull { it.id == aggregateId }
|
||||||
|
|
||||||
override fun readAll(aggregateId: ID): Flow<E> =
|
override fun readAll(aggregateId: ID): List<E> = events
|
||||||
flow {
|
|
||||||
eventBus.forEach { emit(it) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? =
|
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? =
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import io.ktor.server.application.Application
|
|||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
import io.ktor.server.plugins.cors.routing.CORS
|
import io.ktor.server.plugins.cors.routing.CORS
|
||||||
|
|
||||||
fun Application.configureHTTP() {
|
fun Application.configureHttp() {
|
||||||
install(CORS) {
|
install(CORS) {
|
||||||
allowMethod(HttpMethod.Options)
|
allowMethod(HttpMethod.Options)
|
||||||
allowMethod(HttpMethod.Put)
|
allowMethod(HttpMethod.Put)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package eventDemo.plugins
|
package eventDemo.plugins
|
||||||
|
|
||||||
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
|
import eventDemo.libs.event.EventBusInMemory
|
||||||
import eventDemo.shared.command.GameCommandStream
|
import eventDemo.libs.event.EventStreamInMemory
|
||||||
|
import eventDemo.shared.event.GameEventBus
|
||||||
import eventDemo.shared.event.GameEventStream
|
import eventDemo.shared.event.GameEventStream
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.ktor.plugin.Koin
|
import org.koin.ktor.plugin.Koin
|
||||||
import org.koin.logger.slf4jLogger
|
import org.koin.logger.slf4jLogger
|
||||||
@@ -13,13 +13,16 @@ import org.koin.logger.slf4jLogger
|
|||||||
fun Application.configureKoin() {
|
fun Application.configureKoin() {
|
||||||
install(Koin) {
|
install(Koin) {
|
||||||
slf4jLogger()
|
slf4jLogger()
|
||||||
modules(appModule)
|
modules(appKoinModule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val appModule =
|
val appKoinModule =
|
||||||
module {
|
module {
|
||||||
singleOf(::GameEventStream)
|
single {
|
||||||
singleOf(::GameCommandStream)
|
GameEventStream(get(), EventStreamInMemory())
|
||||||
singleOf(::PlayCardCommandHandler)
|
}
|
||||||
|
single {
|
||||||
|
GameEventBus(EventBusInMemory())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import io.ktor.server.resources.Resources
|
|||||||
import io.ktor.server.response.respondText
|
import io.ktor.server.response.respondText
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
|
|
||||||
fun Application.configureRouting() {
|
fun Application.configureHttpRouting() {
|
||||||
install(AutoHeadResponse)
|
install(AutoHeadResponse)
|
||||||
install(Resources)
|
install(Resources)
|
||||||
install(StatusPages) {
|
install(StatusPages) {
|
||||||
|
|||||||
@@ -2,25 +2,22 @@ package eventDemo.plugins
|
|||||||
|
|
||||||
import com.auth0.jwt.JWT
|
import com.auth0.jwt.JWT
|
||||||
import com.auth0.jwt.algorithms.Algorithm
|
import com.auth0.jwt.algorithms.Algorithm
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.call
|
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.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.JWTPrincipal
|
||||||
import io.ktor.server.auth.jwt.jwt
|
import io.ktor.server.auth.jwt.jwt
|
||||||
import io.ktor.server.auth.principal
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.response.respondText
|
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.post
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
fun Application.configureSecurity() {
|
fun Application.configureSecurity() {
|
||||||
// Please read the jwt property from the config file if you are using EngineMain
|
// TODO: read the jwt property from the config file
|
||||||
val jwtAudience = "jwt-audience"
|
val jwtRealm = "Play card game"
|
||||||
val jwtDomain = "https://jwt-provider-domain/"
|
val jwtIssuer = "PlayCardGame"
|
||||||
val jwtRealm = "ktor sample app"
|
|
||||||
val jwtSecret = "secret"
|
val jwtSecret = "secret"
|
||||||
authentication {
|
authentication {
|
||||||
jwt {
|
jwt {
|
||||||
@@ -28,47 +25,35 @@ fun Application.configureSecurity() {
|
|||||||
verifier(
|
verifier(
|
||||||
JWT
|
JWT
|
||||||
.require(Algorithm.HMAC256(jwtSecret))
|
.require(Algorithm.HMAC256(jwtSecret))
|
||||||
.withAudience(jwtAudience)
|
.withIssuer(jwtIssuer)
|
||||||
.withIssuer(jwtDomain)
|
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
validate { credential ->
|
validate { credential ->
|
||||||
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
|
if (credential.payload.getClaim("username").asString() != "") {
|
||||||
}
|
JWTPrincipal(credential.payload)
|
||||||
}
|
|
||||||
}
|
|
||||||
authentication {
|
|
||||||
basic(name = "myauth1") {
|
|
||||||
realm = "Ktor Server"
|
|
||||||
validate { credentials ->
|
|
||||||
if (credentials.name == credentials.password) {
|
|
||||||
UserIdPrincipal(credentials.name)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
challenge { defaultScheme, realm ->
|
||||||
|
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
|
||||||
form(name = "myauth2") {
|
|
||||||
userParamName = "user"
|
|
||||||
passwordParamName = "password"
|
|
||||||
challenge {
|
|
||||||
//
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
authenticate("myauth1") {
|
post("login/{username}") {
|
||||||
get("/protected/route/basic") {
|
val username = call.parameters["username"]
|
||||||
val principal = call.principal<UserIdPrincipal>()!!
|
|
||||||
call.respondText("Hello ${principal.name}")
|
val token =
|
||||||
}
|
JWT
|
||||||
}
|
.create()
|
||||||
authenticate("myauth2") {
|
.withIssuer(jwtIssuer)
|
||||||
get("/protected/route/form") {
|
.withClaim("username", username)
|
||||||
val principal = call.principal<UserIdPrincipal>()!!
|
.withExpiresAt(Date(System.currentTimeMillis() + 60000))
|
||||||
call.respondText("Hello ${principal.name}")
|
.sign(Algorithm.HMAC256(jwtSecret))
|
||||||
}
|
|
||||||
|
call.respond(hashMapOf("token" to token))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package eventDemo.plugins
|
|||||||
|
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
import eventDemo.shared.GameId
|
import eventDemo.shared.GameId
|
||||||
|
import eventDemo.shared.entity.Player.PlayerId
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
@@ -42,6 +43,19 @@ object CommandIdSerializer : KSerializer<CommandId> {
|
|||||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING)
|
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> {
|
object GameIdSerializer : KSerializer<GameId> {
|
||||||
override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString()))
|
override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString()))
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
package eventDemo.plugins
|
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.Application
|
||||||
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.application.install
|
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.routing.routing
|
||||||
import io.ktor.server.websocket.WebSockets
|
import io.ktor.server.websocket.WebSockets
|
||||||
import io.ktor.server.websocket.pingPeriod
|
import io.ktor.server.websocket.pingPeriod
|
||||||
import io.ktor.server.websocket.timeout
|
import io.ktor.server.websocket.timeout
|
||||||
import io.ktor.server.websocket.webSocket
|
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
|
import java.time.Duration
|
||||||
|
|
||||||
fun Application.configureSockets() {
|
fun Application.configureSockets() {
|
||||||
@@ -20,18 +25,26 @@ fun Application.configureSockets() {
|
|||||||
maxFrameSize = Long.MAX_VALUE
|
maxFrameSize = Long.MAX_VALUE
|
||||||
masking = false
|
masking = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.configureWebSocketsGameRoute(
|
||||||
|
eventStream: GameEventStream,
|
||||||
|
eventBus: GameEventBus,
|
||||||
|
) {
|
||||||
routing {
|
routing {
|
||||||
webSocket("/ws") {
|
authenticate {
|
||||||
// websocketSession
|
webSocket("/game") {
|
||||||
for (frame in incoming) {
|
GameCommandHandler(eventStream, incoming, outgoing).init(call.getPlayer())
|
||||||
if (frame is Frame.Text) {
|
GameEventPlayerNotificationSubscriber(eventBus, outgoing).init()
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ApplicationCall.getPlayer() =
|
||||||
|
principal<JWTPrincipal>()!!.run {
|
||||||
|
Player(
|
||||||
|
id = payload.getClaim("playerid").asString(),
|
||||||
|
name = payload.getClaim("username").asString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
15
src/main/kotlin/eventDemo/shared/FrameConverter.kt
Normal file
15
src/main/kotlin/eventDemo/shared/FrameConverter.kt
Normal 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))
|
||||||
@@ -2,19 +2,14 @@ package eventDemo.shared
|
|||||||
|
|
||||||
import eventDemo.libs.event.AggregateId
|
import eventDemo.libs.event.AggregateId
|
||||||
import eventDemo.plugins.GameIdSerializer
|
import eventDemo.plugins.GameIdSerializer
|
||||||
import eventDemo.shared.entity.Game
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [AggregateId] for the [Game].
|
* An [AggregateId] for a game.
|
||||||
*/
|
*/
|
||||||
@JvmInline
|
@JvmInline
|
||||||
@Serializable(with = GameIdSerializer::class)
|
@Serializable(with = GameIdSerializer::class)
|
||||||
value class GameId(
|
value class GameId(
|
||||||
override val id: UUID = UUID.randomUUID(),
|
override val id: UUID = UUID.randomUUID(),
|
||||||
) : AggregateId {
|
) : AggregateId
|
||||||
constructor(id: String) : this(UUID.fromString(id))
|
|
||||||
|
|
||||||
override fun toString(): String = id.toString()
|
|
||||||
}
|
|
||||||
|
|||||||
143
src/main/kotlin/eventDemo/shared/GameState.kt
Normal file
143
src/main/kotlin/eventDemo/shared/GameState.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,28 @@
|
|||||||
package eventDemo.shared.command
|
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 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) },
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package eventDemo.shared.entity
|
package eventDemo.shared.entity
|
||||||
|
|
||||||
|
import eventDemo.plugins.UUIDSerializer
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Play card
|
* A Play card
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface Card {
|
sealed interface Card {
|
||||||
|
val id: UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The color of a card
|
* The color of a card
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +23,10 @@ sealed interface Card {
|
|||||||
Green,
|
Green,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface ColorCard : Card {
|
||||||
|
val color: Color
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A play card with color and number
|
* A play card with color and number
|
||||||
*/
|
*/
|
||||||
@@ -26,8 +34,11 @@ sealed interface Card {
|
|||||||
@SerialName("Simple")
|
@SerialName("Simple")
|
||||||
data class NumericCard(
|
data class NumericCard(
|
||||||
val number: Int,
|
val number: Int,
|
||||||
val color: Color,
|
override val color: Color,
|
||||||
) : Card
|
@Serializable(with = UUIDSerializer::class)
|
||||||
|
override val id: UUID = UUID.randomUUID(),
|
||||||
|
) : Card,
|
||||||
|
ColorCard
|
||||||
|
|
||||||
sealed interface Special : Card
|
sealed interface Special : Card
|
||||||
|
|
||||||
@@ -37,8 +48,13 @@ sealed interface Card {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("Reverse")
|
@SerialName("Reverse")
|
||||||
data class ReverseCard(
|
data class ReverseCard(
|
||||||
val color: Color,
|
override val color: Color,
|
||||||
) : Special
|
@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.
|
* A pass card to pass the turn of the next player.
|
||||||
@@ -46,8 +62,12 @@ sealed interface Card {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("Pass")
|
@SerialName("Pass")
|
||||||
data class PassCard(
|
data class PassCard(
|
||||||
val color: Color,
|
override val color: Color,
|
||||||
) : Special
|
@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.
|
* A play card to force the next player to take 2 card and pass the turn.
|
||||||
@@ -55,24 +75,35 @@ sealed interface Card {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("Plus2")
|
@SerialName("Plus2")
|
||||||
data class Plus2Card(
|
data class Plus2Card(
|
||||||
val color: Color,
|
override val color: Color,
|
||||||
) : Special
|
@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.
|
* A play card to force the next player to take 4 card and pass the turn.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("Plus4")
|
@SerialName("Plus4")
|
||||||
data class Plus4Card(
|
class Plus4Card(
|
||||||
val nextColor: Color,
|
@Serializable(with = UUIDSerializer::class)
|
||||||
) : Special
|
override val id: UUID = UUID.randomUUID(),
|
||||||
|
) : Special,
|
||||||
|
AllColorCard,
|
||||||
|
PassTurnCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A play card to change the color.
|
* A play card to change the color.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("ChangeColor")
|
@SerialName("ChangeColor")
|
||||||
data class ChangeColorCard(
|
class ChangeColorCard(
|
||||||
val nextColor: Color,
|
@Serializable(with = UUIDSerializer::class)
|
||||||
) : Special
|
override val id: UUID = UUID.randomUUID(),
|
||||||
|
) : Special,
|
||||||
|
AllColorCard
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/main/kotlin/eventDemo/shared/entity/Deck.kt
Normal file
53
src/main/kotlin/eventDemo/shared/entity/Deck.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/main/kotlin/eventDemo/shared/entity/Player.kt
Normal file
36
src/main/kotlin/eventDemo/shared/entity/Player.kt
Normal 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() }
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ package eventDemo.shared.event
|
|||||||
import eventDemo.libs.event.Event
|
import eventDemo.libs.event.Event
|
||||||
import eventDemo.shared.GameId
|
import eventDemo.shared.GameId
|
||||||
import eventDemo.shared.entity.Card
|
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> {
|
sealed interface GameEvent : Event<GameId> {
|
||||||
override val id: GameId
|
override val id: GameId
|
||||||
}
|
}
|
||||||
@@ -18,4 +21,59 @@ sealed interface GameEvent : Event<GameId> {
|
|||||||
data class CardIsPlayedEvent(
|
data class CardIsPlayedEvent(
|
||||||
override val id: GameId,
|
override val id: GameId,
|
||||||
val card: Card,
|
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
|
) : GameEvent
|
||||||
|
|||||||
8
src/main/kotlin/eventDemo/shared/event/GameEventBus.kt
Normal file
8
src/main/kotlin/eventDemo/shared/event/GameEventBus.kt
Normal 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
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
package eventDemo.shared.event
|
package eventDemo.shared.event
|
||||||
|
|
||||||
import eventDemo.libs.event.EventStreamInMemory
|
import eventDemo.libs.event.EventBus
|
||||||
|
import eventDemo.libs.event.EventStream
|
||||||
import eventDemo.shared.GameId
|
import eventDemo.shared.GameId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stream to publish and read the played card event.
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
src/main/kotlin/eventDemo/shared/event/GameStateBuilder.kt
Normal file
71
src/main/kotlin/eventDemo/shared/event/GameStateBuilder.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package eventDemo.app.actions
|
|||||||
import eventDemo.configure
|
import eventDemo.configure
|
||||||
import eventDemo.shared.GameId
|
import eventDemo.shared.GameId
|
||||||
import eventDemo.shared.entity.Card
|
import eventDemo.shared.entity.Card
|
||||||
|
import eventDemo.shared.entity.Player
|
||||||
import eventDemo.shared.event.CardIsPlayedEvent
|
import eventDemo.shared.event.CardIsPlayedEvent
|
||||||
import eventDemo.shared.event.GameEventStream
|
import eventDemo.shared.event.GameEventStream
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
@@ -32,6 +33,7 @@ class CardTest :
|
|||||||
|
|
||||||
val id = GameId()
|
val id = GameId()
|
||||||
val card: Card = Card.NumericCard(1, Card.Color.Blue)
|
val card: Card = Card.NumericCard(1, Card.Color.Blue)
|
||||||
|
val player = Player(name = "Nikola")
|
||||||
httpClient()
|
httpClient()
|
||||||
.post("/game/$id/card") {
|
.post("/game/$id/card") {
|
||||||
contentType(Json)
|
contentType(Json)
|
||||||
@@ -41,7 +43,7 @@ class CardTest :
|
|||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
||||||
|
|
||||||
val eventStream = getKoin().get<GameEventStream>()
|
val eventStream = getKoin().get<GameEventStream>()
|
||||||
assertEquals(CardIsPlayedEvent(id, card), eventStream.readLast(id))
|
assertEquals(CardIsPlayedEvent(id, card, player), eventStream.readLast(id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,12 +55,14 @@ class CardTest :
|
|||||||
application {
|
application {
|
||||||
stopKoin()
|
stopKoin()
|
||||||
configure()
|
configure()
|
||||||
|
|
||||||
val eventStream by inject<GameEventStream>()
|
val eventStream by inject<GameEventStream>()
|
||||||
|
val player = Player(name = "Nikola")
|
||||||
eventStream.publish(
|
eventStream.publish(
|
||||||
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow)),
|
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player),
|
||||||
CardIsPlayedEvent(id, card),
|
CardIsPlayedEvent(id, card, player),
|
||||||
// Other game
|
// Other game
|
||||||
CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow)),
|
CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow), player),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user