create bus and subscriber
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -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? =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
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.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
|
||||
|
||||
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
|
||||
|
||||
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) },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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.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
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user