1 Commits
Author SHA1 Message Date
flecomte 256524f63e feat: increase timeout tests
Tests / build (push) Successful in 42s
Tests / lint (push) Successful in 4m15s
Tests / test (push) Failing after 1m23s
2026-03-14 01:31:24 +01:00
290 changed files with 5233 additions and 6059 deletions
-26
View File
@@ -1,26 +0,0 @@
# Version control
.git
.github
# Gradle build outputs / caches (must always be rebuilt fresh inside the image)
.gradle
build/
.kotlin
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea
.vscode
.run
*.iml
*.iws
*.ipr
# Docker-only local files (secrets/env must never be baked into the image)
docker/.env
docker/*.env.docker
docker/*.secret
# Misc
*.hprof
.gradle-docker-cache/
-7
View File
@@ -1,7 +0,0 @@
* text=auto
* eol=lf
*.sh text eol=lf
*.png binary
*.jar binary
gradlew.bat eol=crlf
gradlew text eol=lf
+30 -42
View File
@@ -18,10 +18,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
@@ -31,7 +31,7 @@ jobs:
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Cache Gradle dependencies
uses: actions/cache@v6
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
@@ -48,16 +48,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Restore Gradle cache
uses: actions/cache@v6
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
@@ -73,65 +73,53 @@ jobs:
run: ./gradlew ktlintCheck
- name: Publish ktlint report
uses: yutailang0119/action-ktlint@v5
uses: yutailang0119/action-ktlint@v4
if: always()
with:
report-path: build/reports/ktlint/**/*.xml
continue-on-error: false
test:
needs: build
runs-on: ubuntu-latest
env:
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install a pinned Docker Compose version
run: |
mkdir -p ~/.docker/cli-plugins
curl -fSL https://github.com/docker/compose/releases/download/v5.1.4/docker-compose-linux-x86_64 \
-o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose
docker compose version
- name: Prepare docker secrets
run: |
[ -f docker/postgresql.secret ] || echo -n "changeit" > docker/postgresql.secret
- name: Generate cache key
id: cache-key-generator
run: echo "key=gradle-docker-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Restore Gradle cache (Docker)
uses: actions/cache@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
path: ${{ env.GRADLE_CACHE_DIR }}
key: ${{ steps.cache-key-generator.outputs.key }}
distribution: 'temurin'
java-version: '21'
- name: Restore Gradle cache
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ needs.build.outputs.cache-key }}
restore-keys: |
gradle-docker-${{ runner.os }}-
gradle-${{ runner.os }}-
- name: Prepare cache directory permissions
run: |
mkdir -p "$GRADLE_CACHE_DIR"
chmod -R 777 "$GRADLE_CACHE_DIR"
- name: Grant execute permission to Gradle wrapper
run: chmod +x gradlew
- name: Run tests in Docker
run: docker compose -f docker/docker-compose-test.yaml run tests
- name: Start CI Docker Compose services
run: ./gradlew composeUp -Pci
- name: Shut down Docker services
if: always()
run: docker compose -f docker/docker-compose-test.yaml down -v
- name: Run tests
run: ./gradlew test -x composeUp --no-daemon
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/reports/tests/test
- name: Publish Test Report
uses: dorny/test-reporter@v3
uses: dorny/test-reporter@v1
if: always()
with:
name: JUnit Tests
-1
View File
@@ -37,4 +37,3 @@ out/
/docker/.env
/docker/*.secret
*.hprof
/.gradle-docker-cache/
-32
View File
@@ -1,32 +0,0 @@
<DataSourcesHistory>
<DataSourceFromHistory isRemovedFromProject="false">
<data-source source="LOCAL" name="event-demo@localhost" uuid="af2eabb1-64f7-49de-a94f-be1560baa96a">
<database-info product="PostgreSQL" version="18.4 (Debian 18.4-1.pgdg13+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.7.3" dbms="POSTGRES" exact-version="18.4" exact-driver-version="42.7">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/event-demo</jdbc-url>
<secret-storage>master_key</secret-storage>
<user-name>event-demo</user-name>
<schema-mapping>
<introspection-scope>
<node negative="1">
<node kind="database" qname="@">
<node kind="schema" qname="@" />
</node>
<node kind="database" qname="event-demo">
<node kind="schema">
<name qname="auth" />
<name qname="game" />
</node>
</node>
</node>
</introspection-scope>
</schema-mapping>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</DataSourceFromHistory>
</DataSourcesHistory>
-27
View File
@@ -1,27 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docker composeUp" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="composeUp" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
-133
View File
@@ -1,133 +0,0 @@
# CLAUDE.md — event-demo
Ce fichier donne le contexte du projet pour toute session Claude Code future sur ce dépôt.
## Vue d'ensemble
`event-demo` est un projet démo personnel (Fabrice Lecomte) qui illustre plusieurs patterns
d'architecture backend :
- Event Sourcing
- Event-Driven (bus d'événements asynchrone)
- CQRS (séparation commandes / projections en lecture)
- Architecture Hexagonale (ports & adapters), un dossier par *bounded context*
Le cas d'usage servant de support est un jeu de cartes façon UNO (créer une partie, rejoindre,
jouer une carte, piocher, etc.), avec authentification des joueurs.
Dépôts distants configurés : `gitea` (auto-hébergé, git.gogn.synology.me — remote historique)
et `github` (`flecomte/event-demo`, miroir). Vérifier vers lequel pousser selon le contexte.
## Stack technique
- **Langage** : Kotlin 2.1.21, JDK 21 (toolchain Gradle)
- **Framework serveur** : Ktor 3.5.1 (Netty), DI via Koin 4.2.1
- **Sérialisation** : kotlinx.serialization (JSON)
- **Persistance** :
- PostgreSQL (event store, via HikariCP) + migrations Flyway (`migrations/events/`)
- RabbitMQ (bus d'événements / bus de commandes, via amqp-client)
- **Auth** : JWT (ktor-server-auth-jwt), hash de mot de passe via password4j
- **Infra dev/prod** : Docker Compose (fichiers `docker/docker-compose-{dev,test,prod}.yaml`
incluant des « parts » réutilisables dans `docker/parts/`), reverse proxy Træfik
- **Tests** : Kotest (runner JUnit5), MockK, kotest-extensions-koin, ArchUnit (test d'architecture)
- **Qualité** : ktlint (`ktlint_official`, standard + experimental activés), reporting checkstyle
- **CI** : GitHub Actions (`.github/workflows/tests.yml`) — build/cache Gradle, `ktlintCheck`,
puis tests exécutés **dans Docker** (`docker compose -f docker/docker-compose-test.yaml run tests`)
- **API** : documentée en OpenAPI (`resources/openapi/documentation.yaml`)
## Architecture
Un dossier par *bounded context* sous `src/main/kotlin/eventDemo/contexts/<context>/`, chacun
strictement découpé en 3 couches :
- `domain/` — aucune dépendance vers les autres couches
- `application/` — ne dépend que de `domain`
- `infrastructure/` — dépend de `domain` et `application`
Contexts actuels :
- **`auth`** : `User`, création de compte, login JWT, event store dédié (Postgresql),
projection utilisateur.
- **`game`** : cœur du jeu — `Card`, `DrawPile`/`DiscardPile`, `Player`, `GameId`, commandes
(`JoinTheGameCommand`, `PlayCardCommand`, `ReadyToPlayCommand`, `TakeCartFromDrawPileCommand`),
state machine du jeu via `sealed interface Game` (`GameInit``GameCreated``GameStarted`
`GameEnded`), notifications, projections (liste de parties), listeners/réactions.
Libs transverses dans `libs/` (indépendantes de tout contexte) :
- `bus/` — abstraction `Bus<E>` avec implémentations in-memory et RabbitMQ (fanout exchange)
- `command/``Command`, `CommandUnicityChecker` (empêche la double exécution d'une commande,
cache glissant de 10 min par défaut)
- `eventSource/``Event`, `EventStream` (append/lecture par version, gestion de
`VersionConflictException`), `EventStore` in-memory / Postgresql
- `helpers/`, `serializer/` — utilitaires (conversion de frames WebSocket, sérialiseurs UUID, etc.)
## Patterns notables dans le code
- **Event sourcing** : `Game.loadFromHistory(events)` reconstruit l'état en repliant
(`fold`) les événements sur une state machine scellée, en utilisant la syntaxe Kotlin 2.1
`when` avec garde `if` (ex. `is GameCreatedEvent if this is GameInit -> applyEvent(event)`).
- **CQRS** : écriture via les command handlers (`application/command/handlers`), lecture via des
projections dédiées (`application/projections`), propagées via le bus RabbitMQ, pas de couplage
direct avec l'écriture.
- **Event-driven** : réactions asynchrones (`ReactionListener`, `EventToNotificationSubscriber`)
déclenchées par le bus RabbitMQ (exchange fanout, une queue par abonné).
- **Exceptions métier** : hiérarchie `GameException` / `IllegalActionException` dans
`domain/game/errors`, une exception par règle métier violée (ex.
`NeedMorePlayersToStartGameException`, `ItsNotTheTurnException`).
## Commandes utiles
```shell
./gradlew build # build complet
./gradlew test # tests (JUnit5 via Kotest)
./gradlew ktlintCheck # lint
./gradlew ktlintFormat # auto-format
./gradlew buildFatJar # jar exécutable "all-in-one" (utilisé par le Dockerfile prod)
# Dépendances seules (Postgres, RabbitMQ, Træfik, pgAdmin...) pour lancer l'app en local hors docker
docker compose -f docker/docker-compose-dev.yaml up -d
# Stack de test façon CI
docker compose -f docker/docker-compose-test.yaml up -d
# ou directement (comme en CI) :
docker compose -f docker/docker-compose-test.yaml run tests
# Stack complète en prod
docker compose -f docker/docker-compose-prod.yaml -p event-demo up -d
```
URLs en dev (voir `doc/installation.md`, nécessite Træfik + résolution des `*.traefik.me`) :
API sur `http://api.traefik.me/`, dashboard
Træfik, pgAdmin et RabbitMQ management exposés via des sous-domaines `traefik.me`.
## Conventions de code
- ktlint en mode `ktlint_official` + règles `standard` et `experimental` activées
(voir `.editorconfig`), indentation **2 espaces**, virgules finales (*trailing commas*)
systématiques, wrapping forcé des expressions/signatures multi-lignes.
- Fins de ligne forcées en **LF** (`.gitattributes`), sauf `gradlew.bat` en CRLF.
- Code et identifiants en anglais.
- Style Kotlin idiomatique/fonctionnel : `fold`, `let`, `apply`, `when` exhaustifs, classes/interfaces
scellées (`sealed class`/`sealed interface`) pour modéliser états et événements plutôt que des enums
avec des champs optionnels.
## Pièges connus / choses à savoir avant de toucher au build ou à la CI
- **MockK/ByteBuddy en Docker** : l'auto-attach dynamique de MockK échoue dans les conteneurs
(le handshake SIGQUIT de l'AttachListener JVM time-out). Le `build.gradle.kts` charge donc
l'agent `byte-buddy-agent` de façon statique via `-javaagent` pour les tâches `Test`, afin
que MockK détecte l'instrumentation déjà présente et saute l'attach dynamique. Ne pas retirer
ce bloc sans repenser l'exécution des tests en Docker.
- **Secret Postgres en CI** : `docker/postgresql.secret` est généré à la volée par le workflow
GitHub Actions s'il n'existe pas (`echo -n "changeit" > docker/postgresql.secret`) — normal,
pas un fichier à committer.
- Les tests « officiels » de la CI tournent **dans Docker**, pas directement via `./gradlew test`
sur l'hôte — en cas de comportement différent entre local et CI, vérifier d'abord les
variables d'environnement/versions du `docker-compose-test.yaml`.
## Historique récent (pour contexte)
Le projet a connu un « Massive refactor to build the V2 » (commit `e2d7942`) : passage d'une
architecture par couches techniques plates (`adapter/presenter/domain`) à l'organisation actuelle
par bounded context (`auth`/`game`) avec 3 couches hexagonales chacune.
+27 -1
View File
@@ -2,6 +2,7 @@ Event Demo
==========
- [Installation](./doc/installation.md)
- [What's the demo for ?](#whats-the-demo-for-)
- [What's in this demo](#whats-in-this-demo)
- [The stack](#the-stack)
- [Architecture](./doc/architecture.md)
@@ -17,6 +18,31 @@ of different patterns and architectures.
- The CQRS pattern.
- With the Hexagonal architecture.
What's in this demo
-------------------
- The **event sourcing** pattern.
- The **event driven** pattern.
- The **CQRS** pattern with **command** and **query**.
- A fully **asynchronous** architecture.Concurently process.
- A **pure Kotlin** implementation of **readmodel**/**projection**.
- A **Redis** implementation of **readmodel**/**projection**.
- A **pure Kotlin** implementation of **Event Store**.
- A **Postgresql** implementation of **Event Store**.
- A **pure Kotlin** implementation of **Event Bus**.
- A **RabbitMQ** implementation of **Event Bus**.
- A **Hexagonal** architecture.
- Use of **Web Sockets**.
- Use of the classic **Rest** route.
- Simple usage of the **JWT**.
- The **Ktor** framework.
- The **Koin** Dependency Injection framework
- Concurrently process.
- Use of coroutines.
- Using **docker compose** for the stack with **traefik**.
- Use of **flyway** to migrate the postgresql schema.
The stack
---------
@@ -25,11 +51,11 @@ Language
Framework
- Ktor
- with Koin for Dependency Injection
Database
- Postgresql
- with Flyway
- Redis
- RabbitMQ
Infra
+83 -35
View File
@@ -1,19 +1,22 @@
@file:Suppress("PropertyName")
import org.jlleitschuh.gradle.ktlint.KtlintExtension
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
val ktor_version: String by project
val kotlin_version: String by project
val kotlin_serialization_version: String by project
val logback_version: String by project
val koin_version: String by project
val kotlin_logging_version: String by project
val kotest_version: String by project
plugins {
application
kotlin("jvm") version "2.1.21"
id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
kotlin("jvm") version "2.1.10"
id("io.ktor.plugin") version "3.1.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12"
}
group = "io.github.flecomte"
@@ -26,7 +29,7 @@ application {
}
configure<KtlintExtension> {
version.set("1.8.0")
version.set("1.5.0")
}
ktlint {
reporters {
@@ -46,17 +49,63 @@ java {
tasks.withType<Test>().configureEach {
useJUnitPlatform()
jvmArgs("-Djdk.attach.allowAttachSelf=true", "-XX:+EnableDynamicAgentLoading")
// Dynamic self-attach (used by MockK/ByteBuddy) times out in Docker containers because the
// SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
// pre-installed Instrumentation instance and skips dynamic attach.
doFirst {
val agentJar =
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
?: error("byte-buddy-agent jar not found on test classpath")
jvmArgs("-javaagent:$agentJar")
}
dockerCompose {
val composeFile =
if (project.hasProperty("ci")) {
// Use docker-compose-ci.yaml for the CI
"docker/docker-compose-ci.yaml"
} else {
// Use docker-compose-test.yaml for local tests
"docker/docker-compose-test.yaml"
}
useComposeFiles.set(listOf(composeFile))
setProjectName("event-demo-test")
}
tasks.test {
dependsOn("composeUp")
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
dockerCompose.setProjectName("event-demo-test")
}
tasks.named("run") {
dependsOn("composeUp")
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
dockerCompose.setProjectName("event-demo-dev")
}
tasks.register<Copy>("copyEnv") {
group = "docker"
description = "copy the default dotenv file"
from("docker")
into("docker")
rename {
it.removeSuffix(".template")
}
include(".env.template")
eachFile {
if (File("docker/$name").exists()) {
exclude()
}
}
doLast {
val files =
listOf(
File("docker/pgadmin.secret"),
File("docker/postgresql.secret"),
)
files.forEach {
if (!it.exists()) {
it.writeText("changeit")
}
}
}
}
tasks.composeUp {
dependsOn("copyEnv")
}
dependencies {
@@ -75,24 +124,23 @@ dependencies {
implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation("org.postgresql:postgresql:42.7.13")
implementation("redis.clients:jedis:5.2.0")
implementation("org.postgresql:postgresql:42.7.5")
implementation("com.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0")
implementation("com.password4j:password4j:1.8.4")
// Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13")
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version")
testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
testImplementation("io.mockk:mockk:1.13.17")
}
+11
View File
@@ -0,0 +1,11 @@
Architecture
============
The Workflow
------------
![Workflow Diagram](./schemas/Workflow.svg)
The business Entities
---------------------
![Entities and Projections Diagram](./schemas/Entities.svg)
+5 -15
View File
@@ -1,33 +1,23 @@
Installation
============
To run the stack in production:
To run the stack:
```shell
docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d
```
To run only the app dependencies in development mode and run the app localy (not in docker):
```shell
docker compose -f docker\docker-compose-dev.yaml up -d
```
To run the tests in docker (it's designed for the CI):
```shell
docker compose -f docker\docker-compose-test.yaml up -d
```
Api url:
- [Backend API](http://api.traefik.me/)
- [Frontend web site](http://app.traefik.me/)
- [Frontend web site](http://app.traefik.me/) (WIP)
Exposed url on dev env:
Exposed url on test env:
- [PostgreSql](http://localhost:5432/)
- [Redis](http://localhost:6379/)
- [RabbitMQ](http://localhost:15672/)
Admin service URL:
- [Træfik dashboard](http://traefik.traefik.me/)
- [Redis insight](http://insight.redis.traefik.me/)
- [pgAdmin](http://pgadmin.postgresql.traefik.me/)
- [RabbitMQ management](http://management.rabbitmq.traefik.me/)
+93
View File
@@ -0,0 +1,93 @@
@startuml
'https://plantuml.com/class-diagram
left to right direction
class GameList <<Projection>> {
+ status: Status
}
class GameState <<Projection>> {
+ players: List<Player>
+ currentPlayerTurn: Player
+ lastCardPlayer: Player
+ colorOnCurrentStack: Color
+ direction: Direction
+ readyPlayers: List<Player>
+ deck: Deck
+ isStarted: Boolean
+ playerWins: List<Player>
}
interface Card {
+ id: UUID
}
enum Color {
+ Blue
+ Red
+ Yellow
+ Green
}
class GameId {
+ id: UUID
}
class Player {
+ id: PlayerId
+ name: String
}
class Deck {
+ stack: Stack
+ discard: Discard
+ playersHands: PlayersHands
}
class Stack {
+ cards: List<Card>
+ shuffle()
}
class Discard {
+ cards: List<Card>
}
class PlayerHands {
+ map: Map<PlayerId, List<Card>>
}
class NumericCard {
+ number: Int
+ color: Color
}
class ReverseCard {
+ color: Color
}
class PassCard {
+ color: Color
}
class Plus2Card {
+ color: Color
}
class Plus4Card
class ChangeColorCard
GameState *-- Deck
GameState o-- "many" Player
Deck *-- PlayerHands
PlayerHands *-- "many" Card
PlayerHands o-- "many" Player
Stack *-- "many" Card
Discard *-- "many" Card
Deck *-- Stack
Deck *-- Discard
GameState *-- GameId
Card <|--- NumericCard
Card <|--- ReverseCard
Card <|--- PassCard
Card <|--- ChangeColorCard
Card <|--- Plus2Card
Card <|--- Plus4Card
ReverseCard o-- Color
NumericCard o-- Color
PassCard o-- Color
Plus2Card o-- Color
GameList *-- GameId
GameList o-- "many" Player
@enduml
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

+84
View File
@@ -0,0 +1,84 @@
@startuml
'https://plantuml.com/use-case-diagram
package Legend {
usecase (Queries) #7693C4
usecase (Projections) #AB64C9
usecase (Events) #5FAD56
}
actor User
entity Query #7693C4
entity Command #5FAD56
entity Event #5FAD56
entity Projection #AB64C9
database Postgresql
database Redis
queue RabbitMQ
usecase (Web socket adapter) #5FAD56
usecase (Command handler) #5FAD56
usecase/ (Action) #5FAD56
usecase (Event handler) #5FAD56
usecase (Version builder) #5FAD56
usecase (Event store) #5FAD56
usecase (Event stream) #5FAD56
usecase (Event bus) #5FAD56
usecase/ (Reaction listener) #5FAD56
usecase/ (Projection builder) #AB64C9
usecase (Projection repository) #AB64C9
usecase (Projection bus) #AB64C9
usecase (Controller) #7693C4
User -> Query : <<create>>
Command <- User : <<create>>
User ---> (Controller) : Get \nprojection
User <-- (Controller) : Returns \nprojection
(Controller) --------> (Projection repository) : Get \nprojection
User -> (Web socket adapter) : Send \ncommand
(Web socket adapter) --> (Command handler) : Send \ncommand
(Web socket adapter) ...> User : Send notification \n(error or success)
(Command handler) ..> (Web socket adapter) : Send \nnotification
(Command handler) -> (Action) : Execute action
(Command handler) <- (Action) : Returns \nevent builder
(Command handler) ---> (Event handler) : Dispatch \nevent \n(send an event builder)
(Command handler) --> Event : <<Create>>
(Event handler) --> (Event store) : Publish \nevent
(Event handler) <-- (Reaction listener) : Dispatch \n new event
(Version builder) <- (Event handler) : build next version
note "Acquire a lock, \nget the next event version, \nand then, build the event " as EventHandlerNote
EventHandlerNote <-- (Event handler)
(Event store) -left-> (Event stream)
(Event store) ---> (Event bus) : Publish \nevent
(Event stream) --> Postgresql : Persist \nevent
(Event bus) -> RabbitMQ : Publish \nevent
(Event bus) -> RabbitMQ : Subscribe \nto event
(Event bus) <. RabbitMQ : Emit event
(Reaction listener) ---> (Projection bus) : Subscribe
(Reaction listener) <.. (Projection bus) : Emit projection
(Projection bus) <- (Projection repository) : Publish \nprojection
RabbitMQ <- (Projection bus) : Publish \nprojection
RabbitMQ <- (Projection bus) : Subscribe \nto projection
RabbitMQ .> (Projection bus) : Emit projection
(Event bus) <---- (Projection repository) : Subscribe
(Event bus) ..> (Projection repository) : Emit event
(Projection repository) --> Redis : Persist \nprojection
(Projection repository) <- Redis : Get \nprojection
(Projection repository) -> (Projection builder) : Build \nprojection
(Projection builder) --> Projection : <<create projection>>
@enduml
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

-2
View File
@@ -1,2 +0,0 @@
POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo
RABBITMQ_URL=rabbitmq
+1
View File
@@ -0,0 +1 @@
PGADMIN_DEFAULT_EMAIL=
+4 -4
View File
@@ -1,5 +1,5 @@
# Stage 1: Cache Gradle dependencies
FROM gradle:9.6.1-jdk21-alpine AS cache
FROM gradle:latest AS cache
RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME=/home/gradle/cache_home
COPY build.gradle.* gradle.properties /home/gradle/app/
@@ -7,7 +7,7 @@ WORKDIR /home/gradle/app
RUN gradle build -i -x check
# Stage 2: Build Application
FROM gradle:9.6.1-jdk21-alpine AS build
FROM gradle:latest AS build
COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
@@ -16,8 +16,8 @@ WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon
# Stage 3: Create the Runtime Image
FROM eclipse-temurin:21-jre-alpine AS runtime
FROM amazoncorretto:21 AS runtime
EXPOSE 8080
RUN mkdir /app
COPY --from=build /home/gradle/src/build/libs/*-all.jar /app/event-demo-all.jar
COPY --from=build /home/gradle/src/build/libs/*.jar /app/event-demo-all.jar
ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"]
-10
View File
@@ -1,10 +0,0 @@
# Image officielle Gradle avec JDK 21 déjà installé
FROM gradle:9.6.1-jdk21
WORKDIR /app
# Copie du wrapper et des fichiers de config en premier pour profiter du cache Docker
COPY build.gradle.kts settings.gradle.kts ./
# Lance les tests Kotlin
CMD ["gradle", "test", "--no-daemon"]
+6
View File
@@ -0,0 +1,6 @@
name: event-demo-test
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-traefik.yaml
-17
View File
@@ -1,17 +0,0 @@
name: event-demo-dev
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-tools-local.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
pgadmin:
environment:
PGADMIN_DEFAULT_PASSWORD: "changeit"
-13
View File
@@ -5,16 +5,3 @@ include:
- parts/docker-compose-app.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
volumes:
- ./postgresql.secret:/run/secrets/postgresql_password:ro
pgadmin:
environment:
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
volumes:
- ./pgadmin.secret:/run/secrets/pgadmin_password:ro
+2 -6
View File
@@ -2,10 +2,6 @@ name: event-demo-test
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-test.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
+2 -2
View File
@@ -10,8 +10,8 @@ services:
condition: service_healthy
rabbitmq:
condition: service_healthy
env_file:
- ../.env.docker
redis:
condition: service_healthy
labels:
- "traefik.http.routers.api.rule=Host(`api.traefik.me`)"
- "traefik.http.services.api.loadbalancer.server.port=8080"
@@ -1,4 +1,8 @@
services:
redis:
ports:
- "6379:6379"
postgresql:
ports:
- "5432:5432"
+19 -2
View File
@@ -1,4 +1,12 @@
services:
redis:
image: redis/redis-stack:7.4.0-v3
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
volumes:
- redis_data:/data
- redisinsight_data:/redisinsight
flyway:
image: flyway/flyway
command: migrate
@@ -11,17 +19,20 @@ services:
restart: no
postgresql:
image: postgres:18.4
image: postgres:17.4
command: postgres -c 'max_connections=500'
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
POSTGRES_USER: event-demo
secrets:
- postgresql_password
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
interval: 1s
timeout: 5s
retries: 10
volumes:
- postgresql_data:/var/lib/postgresql
- postgresql_data:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:4-management-alpine
@@ -36,6 +47,12 @@ services:
volumes:
- rabbitmq_data:/var/lib/rabbitmq/
secrets:
postgresql_password:
file: ../postgresql.secret
volumes:
redis_data:
redisinsight_data:
postgresql_data:
rabbitmq_data:
-20
View File
@@ -1,20 +0,0 @@
services:
tests:
build:
context: ../..
dockerfile: docker/DockerfileTest
volumes:
- ${GRADLE_CACHE_DIR:-gradle-cache}:/home/gradle/.gradle
- ../..:/app
depends_on:
flyway:
condition: service_completed_successfully
postgresql:
condition: service_healthy
rabbitmq:
condition: service_healthy
env_file:
- ../.env.docker
volumes:
gradle-cache:
@@ -1,18 +0,0 @@
services:
pgadmin:
environment:
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
configs:
- source: pgpass
target: /pgpass
mode: 0600
uid: "5050"
gid: "5050"
- source: servers_json
target: /pgadmin4/servers.json
configs:
pgpass:
content: |
*:*:*:event-demo:changeit
+13 -21
View File
@@ -2,39 +2,31 @@ services:
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev}
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
secrets:
- pgadmin_password
volumes:
- pgadmin_data:/var/lib/pgadmin
configs:
- source: servers_json
target: /pgadmin4/servers.json
labels:
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
- "traefik.http.services.pgadmin.loadbalancer.server.port=80"
redis:
labels:
- "traefik.http.routers.redisinsight.rule=Host(`insight.redis.traefik.me`)"
- "traefik.http.routers.redisinsight.service=redisinsight"
- "traefik.http.services.redisinsight.loadbalancer.server.port=8001"
rabbitmq:
labels:
- "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)"
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
configs:
servers_json:
content: |
{
"Servers": {
"1": {
"Name": "Event demo",
"Group": "Servers",
"Host": "postgresql",
"Port": 5432,
"MaintenanceDB": "event-demo",
"Username": "event-demo",
"PassFile": "/pgpass",
"SSLMode": "prefer"
}
}
}
secrets:
pgadmin_password:
file: ../pgadmin.secret
volumes:
pgadmin_data:
+1 -1
View File
@@ -1,6 +1,6 @@
services:
traefik:
image: traefik:3.7.9
image: traefik:3.3.4
command:
- "--api.insecure=true"
- "--api.dashboard=true"
+7 -7
View File
@@ -1,9 +1,9 @@
ktor_version=3.5.1
kotlin_version=2.1.21
kotlin_serialization_version=1.11.0
logback_version=1.5.38
koin_version=4.2.1
kotlin_logging_version=8.0.4
kotest_version=6.2.2
ktor_version=3.1.1
kotlin_version=2.1.10
kotlin_serialization_version=1.8.0
logback_version=1.5.17
koin_version=4.0.2
kotlin_logging_version=7.0.5
kotest_version=6.0.0.M2
kotlin.code.style=official
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,5 +1,4 @@
create schema game;
create table game.game_event_stream (
create table event_stream (
id uuid not null primary key,
aggregate_id uuid not null,
version int not null,
@@ -1,8 +0,0 @@
create schema auth;
create table auth.user_event_stream (
id uuid not null primary key,
aggregate_id uuid not null,
version int not null,
data jsonb not null,
unique(aggregate_id, version)
);
@@ -1,6 +0,0 @@
create table auth.user (
id uuid not null primary key,
username text not null,
unique(id),
unique(username)
);
+40 -644
View File
@@ -1,264 +1,58 @@
openapi: "3.0.3"
info:
title: "event_demo API"
description: |
API for the event_demo project: a small event-sourced Uno-like card game.
The API is split in two parts:
- a classic REST API used to authenticate, create users, list games and
replay a game's history.
- a WebSocket endpoint (`/games/{id}`) used to play a game in real time:
the client sends `GameCommand` messages and receives `Notification`
messages back. See the `x-websocket-channels` section below for details,
since WebSockets are not natively described by OpenAPI 3.0.
Authentication is done with a JWT bearer token obtained from `/login/{username}`.
Note that most polymorphic messages (`GameCommand`, `Notification`) are
discriminated by a `type` field whose value is the fully-qualified Kotlin
class name of the payload (since no custom serial name is declared for
these types), while `Card` uses short discriminator values
(`Simple`, `Reverse`, `Pass`, `Plus2`, `Plus4`, `ChangeColor`).
version: "2.0.0"
description: "event_demo API"
version: "1.0.0"
servers:
- url: "https://event_demo"
tags:
- name: Auth
description: User registration and authentication
- name: Games
description: Listing games and reading/playing their state
security:
- bearerAuth: []
paths:
"/login/{username}":
post:
tags: [Auth]
summary: Log in and obtain a JWT
security: []
parameters:
- name: username
in: path
required: true
schema:
type: string
- name: password
in: query
description: The user's plain-text password.
required: true
schema:
type: string
responses:
200:
description: Successful login
content:
application/json:
schema:
$ref: "#/components/schemas/LoginResponse"
400:
description: Unknown username or invalid password
"/users/create":
post:
tags: [Auth]
summary: Create a new user account
description: Requires a valid JWT (any authenticated user can create new users).
parameters:
- name: username
in: query
required: true
schema:
type: string
- name: password
in: query
description: The plain-text password, hashed server-side before being stored.
required: true
schema:
type: string
responses:
200:
description: The newly created user
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserResponse"
401:
$ref: "#/components/responses/Unauthorized"
"/games":
"/game/{id}/card/last":
get:
tags: [Games]
summary: List all known games
description: Returns up to the 100 most recent games (pagination is not yet exposed on this route).
description: get the last card played
responses:
200:
description: The list of games
description: The last card
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/GameList"
401:
$ref: "#/components/responses/Unauthorized"
"/games/{id}":
get:
tags: [Games]
summary: Replay a game's full notification history
description: |
Rebuilds every notification that would have been sent to the calling
player since the beginning of the game (from its event stream), so a
client reconnecting can catch up on the current game state.
This same path also accepts a WebSocket upgrade to play the game live,
see `x-websocket-channels` at the root of this document.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: The full list of notifications for this game, from this player's point of view
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Notification"
401:
$ref: "#/components/responses/Unauthorized"
404:
description: No game found for this id
x-websocket-channels:
"/games/{id}":
description: |
WebSocket endpoint to join and play a game in real time. Requires the
same JWT bearer authentication as the REST routes (sent the same way,
e.g. via the `Authorization` header during the WebSocket handshake).
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
send:
description: Commands sent by the client to act on the game.
schema:
$ref: "#/components/schemas/GameCommand"
receive:
description: Notifications sent by the server as the game progresses.
schema:
$ref: "#/components/schemas/Notification"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
JWT obtained from `POST /login/{username}`. It carries the `username`
and `userid` claims and currently expires 60 seconds after issuance.
responses:
Unauthorized:
description: Missing, invalid or expired JWT
content:
text/plain:
schema:
type: string
example: "Token is not valid or has expired"
schemas:
LoginResponse:
type: object
required: [token]
properties:
token:
type: string
description: JWT bearer token to use on subsequent requests.
CreateUserResponse:
type: object
required: [id]
properties:
id:
type: string
format: uuid
HttpErrorBadRequest:
type: object
description: Generic problem-details style error body used by some validation failures.
properties:
title:
type: string
statusCode:
type: integer
invalidParams:
type: array
items:
$ref: "#/components/schemas/InvalidParam"
InvalidParam:
type: object
required: [name, reason]
properties:
name:
type: string
reason:
type: string
PlayerId:
type: string
format: uuid
Player:
type: object
required: [name, userId, hand, id]
properties:
name:
type: string
userId:
type: string
format: uuid
id:
$ref: "#/components/schemas/PlayerId"
hand:
description: The set of cards currently held by the player.
type: array
items:
$ref: "#/components/schemas/Card"
GameList:
type: object
required: [aggregateId, status, players, winners]
components:
schemas:
Card:
oneOf:
- $ref: "#/components/schemas/SimpleCard"
- $ref: "#/components/schemas/ReverseCard"
- $ref: "#/components/schemas/PassCard"
- $ref: "#/components/schemas/Plus2Card"
- $ref: "#/components/schemas/Plus4Card"
- $ref: "#/components/schemas/ChangeColorCard"
SimpleCard:
properties:
aggregateId:
type: string
format: uuid
status:
$ref: "#/components/schemas/GameStatus"
players:
type: array
items:
$ref: "#/components/schemas/Player"
winners:
type: array
items:
$ref: "#/components/schemas/PlayerId"
GameStatus:
type: string
enum:
- OPENING
- IS_STARTED
- FINISH
- CANCELED
number:
type: integer
color:
$ref: "#/components/schemas/CardColor"
ReverseCard:
properties:
color:
$ref: "#/components/schemas/CardColor"
PassCard:
properties:
color:
$ref: "#/components/schemas/CardColor"
Plus2Card:
properties:
color:
$ref: "#/components/schemas/CardColor"
Plus4Card:
properties:
nextColor:
$ref: "#/components/schemas/CardColor"
ChangeColorCard:
properties:
nextColor:
$ref: "#/components/schemas/CardColor"
CardColor:
type: string
enum:
@@ -266,401 +60,3 @@ components:
- Red
- Yellow
- Green
Card:
description: >
A playing card. Discriminated by the "type" field using the short
names declared on each Kotlin subtype (@SerialName), unlike
GameCommand/Notification below.
oneOf:
- $ref: "#/components/schemas/NumericCard"
- $ref: "#/components/schemas/ReverseCard"
- $ref: "#/components/schemas/PassCard"
- $ref: "#/components/schemas/Plus2Card"
- $ref: "#/components/schemas/Plus4Card"
- $ref: "#/components/schemas/ChangeColorCard"
discriminator:
propertyName: type
mapping:
Simple: "#/components/schemas/NumericCard"
Reverse: "#/components/schemas/ReverseCard"
Pass: "#/components/schemas/PassCard"
Plus2: "#/components/schemas/Plus2Card"
Plus4: "#/components/schemas/Plus4Card"
ChangeColor: "#/components/schemas/ChangeColorCard"
NumericCard:
description: A numbered card (0-9) of a given color.
type: object
required: [type, id, number, color]
properties:
type:
type: string
enum: [Simple]
id:
type: string
format: uuid
number:
type: integer
minimum: 0
maximum: 9
color:
$ref: "#/components/schemas/CardColor"
ReverseCard:
description: Reverses the turn order.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Reverse]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
PassCard:
description: Skips the next player's turn.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Pass]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
Plus2Card:
description: Forces the next player to draw 2 cards and skips their turn.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Plus2]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
Plus4Card:
description: >
Forces the next player to draw 4 cards and skips their turn. The new
color is chosen separately, via the `chosenColor` field of
PlayCardCommand, and is not part of the card itself.
type: object
required: [type, id]
properties:
type:
type: string
enum: [Plus4]
id:
type: string
format: uuid
ChangeColorCard:
description: >
Changes the current color. The new color is chosen separately, via
the `chosenColor` field of PlayCardCommand, and is not part of the
card itself.
type: object
required: [type, id]
properties:
type:
type: string
enum: [ChangeColor]
id:
type: string
format: uuid
GameCommand:
description: >
A command sent by the client over the game WebSocket to act on a game.
Discriminated by "type", whose value is the fully-qualified Kotlin
class name of the command (no `@SerialName` is declared on these
types).
oneOf:
- $ref: "#/components/schemas/JoinTheGameCommand"
- $ref: "#/components/schemas/PlayCardCommand"
- $ref: "#/components/schemas/ReadyToPlayCommand"
- $ref: "#/components/schemas/TakeCartFromDrawPileCommand"
discriminator:
propertyName: type
mapping:
eventDemo.contexts.game.application.command.models.JoinTheGameCommand: "#/components/schemas/JoinTheGameCommand"
eventDemo.contexts.game.application.command.models.PlayCardCommand: "#/components/schemas/PlayCardCommand"
eventDemo.contexts.game.application.command.models.ReadyToPlayCommand: "#/components/schemas/ReadyToPlayCommand"
eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand: "#/components/schemas/TakeCartFromDrawPileCommand"
JoinTheGameCommand:
description: Join an existing (not yet started) game.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.JoinTheGameCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId]
properties:
aggregateId:
type: string
format: uuid
description: The id of the game to join.
ReadyToPlayCommand:
description: Mark the calling player as ready, so the game can start once everyone is ready.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.ReadyToPlayCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
TakeCartFromDrawPileCommand:
description: Draw a card from the draw pile.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
PlayCardCommand:
description: Play a card from the calling player's hand.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.PlayCardCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId, card]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
card:
$ref: "#/components/schemas/Card"
chosenColor:
description: The color to switch to, only required when playing a Plus4Card or ChangeColorCard.
nullable: true
allOf:
- $ref: "#/components/schemas/CardColor"
Notification:
description: >
A notification sent by the server, either replayed by `GET /games/{id}`
or streamed live over the game WebSocket. Discriminated by "type",
whose value is the fully-qualified Kotlin class name of the
notification (no `@SerialName` is declared on these types).
oneOf:
- $ref: "#/components/schemas/WelcomeToTheGameNotification"
- $ref: "#/components/schemas/PlayerAsJoinTheGameNotification"
- $ref: "#/components/schemas/PlayerWasReadyNotification"
- $ref: "#/components/schemas/TheGameWasStartedNotification"
- $ref: "#/components/schemas/ItsTheTurnOfNotification"
- $ref: "#/components/schemas/PlayerAsPlayACardNotification"
- $ref: "#/components/schemas/YourNewCardNotification"
- $ref: "#/components/schemas/PlayerHavePassNotification"
- $ref: "#/components/schemas/PilesShuffledNotification"
- $ref: "#/components/schemas/PlayerWinNotification"
discriminator:
propertyName: type
mapping:
eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification: "#/components/schemas/WelcomeToTheGameNotification"
eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification: "#/components/schemas/PlayerAsJoinTheGameNotification"
eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification: "#/components/schemas/PlayerWasReadyNotification"
eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification: "#/components/schemas/TheGameWasStartedNotification"
eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification: "#/components/schemas/ItsTheTurnOfNotification"
eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification: "#/components/schemas/PlayerAsPlayACardNotification"
eventDemo.contexts.game.application.notification.models.YourNewCardNotification: "#/components/schemas/YourNewCardNotification"
eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification: "#/components/schemas/PlayerHavePassNotification"
eventDemo.contexts.game.application.notification.models.PilesShuffledNotification: "#/components/schemas/PilesShuffledNotification"
eventDemo.contexts.game.application.notification.models.PlayerWinNotification: "#/components/schemas/PlayerWinNotification"
WelcomeToTheGameNotification:
description: Sent to a player right after they join a game, listing all players currently in it.
type: object
required: [type, id, players]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification]
id:
type: string
format: uuid
players:
type: array
items:
$ref: "#/components/schemas/Player"
PlayerAsJoinTheGameNotification:
description: Sent to the other players when a new player joins the game.
type: object
required: [type, id, player]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification]
id:
type: string
format: uuid
player:
$ref: "#/components/schemas/Player"
PlayerWasReadyNotification:
description: Sent to all players when a player marks themselves as ready.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
TheGameWasStartedNotification:
description: Sent to each player when the game starts, with their initial hand.
type: object
required: [type, id, hand]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification]
id:
type: string
format: uuid
hand:
type: array
items:
$ref: "#/components/schemas/Card"
ItsTheTurnOfNotification:
description: Sent to all players to indicate whose turn it now is.
type: object
required: [type, id, player]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification]
id:
type: string
format: uuid
player:
$ref: "#/components/schemas/Player"
PlayerAsPlayACardNotification:
description: Sent to all players when a player plays a card.
type: object
required: [type, id, playerId, card]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
card:
$ref: "#/components/schemas/Card"
YourNewCardNotification:
description: Sent to a player with the cards they just drew from the draw pile.
type: object
required: [type, id, cards]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.YourNewCardNotification]
id:
type: string
format: uuid
cards:
type: array
items:
$ref: "#/components/schemas/Card"
PlayerHavePassNotification:
description: Sent to the other players when a player draws a card and passes their turn.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
PilesShuffledNotification:
description: Sent to all players when the discard pile is reshuffled into the draw pile.
type: object
required: [type, id]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PilesShuffledNotification]
id:
type: string
format: uuid
PlayerWinNotification:
description: Sent to all players when a player wins the game.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerWinNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.infrastructure.persistence.eventBus
package eventDemo.adapter.infrastructureLayer.event
import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory
import java.util.UUID
@@ -1,8 +1,8 @@
package eventDemo.contexts.game.infrastructure.persistence.eventBus
package eventDemo.adapter.infrastructureLayer.event
import com.rabbitmq.client.ConnectionFactory
import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json
@@ -0,0 +1,14 @@
package eventDemo.adapter.infrastructureLayer.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventStore
import eventDemo.libs.event.EventStoreInMemory
/**
* A stream to publish and read the played card event.
*/
class GameEventStoreInMemory :
GameEventStore,
EventStore<GameEvent, GameId> by EventStoreInMemory()
@@ -0,0 +1,21 @@
package eventDemo.adapter.infrastructureLayer.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventStore
import eventDemo.libs.event.EventStoreInPostgresql
import kotlinx.serialization.json.Json
import javax.sql.DataSource
/**
* A stream to publish and read the played card event.
*/
class GameEventStoreInPostgresql(
dataSource: DataSource,
) : GameEventStore,
EventStore<GameEvent, GameId> by EventStoreInPostgresql(
dataSource,
{ Json.encodeToString(it) },
{ Json.decodeFromString(it) },
)
@@ -0,0 +1,44 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameList
import eventDemo.business.event.projection.GameListRepository
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
import io.github.oshai.kotlinlogging.withLoggingContext
/**
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
*/
class GameListRepositoryInMemory : GameListRepository {
private val projectionsRepository =
ProjectionRepositoryInMemory(
applyToProjection = GameList::apply,
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* Get the last version of the [GameState] from the all eventStream.
*
* It fetches it from the local cache if possible, otherwise it builds it.
*/
override fun getList(): List<GameList> =
projectionsRepository.getList()
}
@@ -0,0 +1,51 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameList
import eventDemo.business.event.projection.GameListRepository
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.serialization.json.Json
import redis.clients.jedis.UnifiedJedis
/**
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
*/
class GameListRepositoryInRedis(
jedis: UnifiedJedis,
) : GameListRepository {
private val projectionsRepository =
ProjectionRepositoryInRedis(
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
projectionClass = GameList::class,
projectionToJson = { Json.encodeToString(GameList.serializer(), it) },
jsonToProjection = { Json.decodeFromString(GameList.serializer(), it) },
applyToProjection = GameList::apply,
jedis = jedis,
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* Get the last version of the [GameState] from the all eventStream.
*
* It fetches it from the local cache if possible, otherwise it builds it.
*/
override fun getList(): List<GameList> =
projectionsRepository.getList()
}
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.infrastructure.persistence.projections.bus
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.contexts.game.application.ports.GameProjectionBus
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
import eventDemo.business.event.projection.GameProjection
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory
import java.util.UUID
@@ -1,8 +1,8 @@
package eventDemo.contexts.game.infrastructure.persistence.projections.bus
package eventDemo.adapter.infrastructureLayer.event.projection
import com.rabbitmq.client.ConnectionFactory
import eventDemo.contexts.game.application.ports.GameProjectionBus
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
import eventDemo.business.event.projection.GameProjection
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json
@@ -0,0 +1,41 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.GameStateRepository
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
import io.github.oshai.kotlinlogging.withLoggingContext
/**
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
*/
class GameStateRepositoryInMemory : GameStateRepository {
private val projectionsRepository =
ProjectionRepositoryInMemory(
applyToProjection = GameState::apply,
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* Get the [GameState].
*/
override fun get(gameId: GameId): GameState =
projectionsRepository.get(gameId)
}
@@ -0,0 +1,49 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.GameStateRepository
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.serialization.json.Json
import redis.clients.jedis.UnifiedJedis
/**
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
*/
class GameStateRepositoryInRedis(
jedis: UnifiedJedis,
) : GameStateRepository {
private val projectionsRepository =
ProjectionRepositoryInRedis(
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
projectionClass = GameState::class,
projectionToJson = { Json.encodeToString(GameState.serializer(), it) },
jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) },
applyToProjection = GameState::apply,
jedis = jedis,
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* Get the [GameState].
*/
override fun get(gameId: GameId): GameState =
projectionsRepository.get(gameId)
}
@@ -0,0 +1,67 @@
package eventDemo.adapter.interfaceLayer.query
import eventDemo.business.command.GameCommandHandler
import eventDemo.business.command.command.GameCommand
import eventDemo.business.entity.GameId
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
import eventDemo.business.notification.Notification
import eventDemo.libs.fromFrameChannel
import eventDemo.libs.toObjectChannel
import io.github.oshai.kotlinlogging.withLoggingContext
import io.ktor.server.auth.authenticate
import io.ktor.server.routing.Route
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import java.util.UUID
@DelicateCoroutinesApi
fun Route.gameWebSocket(
playerNotificationListener: PlayerNotificationListener,
commandHandler: GameCommandHandler,
) {
authenticate {
webSocket("/games/new") {
runWebSocket(GameId(), commandHandler, playerNotificationListener)
}
webSocket("/games/{id}") {
val gameId = GameId(UUID.fromString(call.parameters["id"]!!))
runWebSocket(gameId, commandHandler, playerNotificationListener)
}
}
}
@DelicateCoroutinesApi
private fun DefaultWebSocketServerSession.runWebSocket(
gameId: GameId,
commandHandler: GameCommandHandler,
playerNotificationListener: PlayerNotificationListener,
) {
val currentPlayer = call.getPlayerCredentials()
val incomingFrameChannel: ReceiveChannel<GameCommand> = toObjectChannel(incoming)
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
withLoggingContext("currentPlayer" to currentPlayer.toString()) {
val notificationListener =
playerNotificationListener.startListening(
currentPlayer,
gameId,
) { outgoingFrameChannel.trySendBlocking(it) }
// TODO change GlobalScope
GlobalScope.launch {
commandHandler.handleIncomingPlayerCommands(
currentPlayer,
gameId,
incomingFrameChannel,
outgoingFrameChannel,
)
notificationListener.close()
}
}
}
@@ -1,6 +1,6 @@
package eventDemo.contexts.game.infrastructure.rest
package eventDemo.adapter.interfaceLayer.query
import eventDemo.domain.event.projection.GameListRepository
import eventDemo.business.event.projection.GameListRepository
import io.ktor.resources.Resource
import io.ktor.server.auth.authenticate
import io.ktor.server.resources.get
@@ -15,7 +15,7 @@ class Games
/**
* API routes to show all games.
*/
fun Route.gamesListRoute(gameListRepository: GameListRepository) {
fun Route.readGamesList(gameListRepository: GameListRepository) {
authenticate {
// Read the last played card on the game.
get<Games> {
@@ -0,0 +1,14 @@
package eventDemo.adapter.interfaceLayer.query
import eventDemo.business.entity.Player
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
internal fun ApplicationCall.getPlayerCredentials() =
principal<JWTPrincipal>()!!.run {
Player(
id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(),
)
}
@@ -0,0 +1,53 @@
package eventDemo.adapter.interfaceLayer.query
import eventDemo.business.entity.GameId
import eventDemo.business.event.projection.GameStateRepository
import eventDemo.configuration.serializer.GameIdSerializer
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.auth.authenticate
import io.ktor.server.resources.get
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import kotlinx.serialization.Serializable
@Serializable
@Resource("/games/{id}")
class Game(
@Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@Resource("card/last")
class Card(
val game: Game,
)
@Serializable
@Resource("state")
class State(
val game: Game,
)
}
/**
* API routes to read the game state.
*/
fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
authenticate {
// Read the last played card on the game.
get<Game.Card> { body ->
gameStateRepository
.get(body.game.id)
.cardOnCurrentStack
?.let { call.respond(it) }
?: call.response.status(HttpStatusCode.BadRequest)
}
// Read the last played card on the game.
get<Game.State> { body ->
val state = gameStateRepository.get(body.game.id)
call.respond(state)
}
}
}
@@ -1,4 +1,4 @@
package eventDemo.contexts.game.application.command.handlers
package eventDemo.business.command
class CommandException(
override val message: String,
@@ -0,0 +1,27 @@
package eventDemo.business.command
import eventDemo.business.command.action.ICantPlay
import eventDemo.business.command.action.IWantToJoinTheGame
import eventDemo.business.command.action.IWantToPlayCard
import eventDemo.business.command.action.IamReadyToPlay
import eventDemo.business.command.command.GameCommand
import eventDemo.business.command.command.ICantPlayCommand
import eventDemo.business.command.command.IWantToJoinTheGameCommand
import eventDemo.business.command.command.IWantToPlayCardCommand
import eventDemo.business.command.command.IamReadyToPlayCommand
import eventDemo.business.event.event.GameEvent
class GameCommandActionRunner(
private val iWantToPlayCard: IWantToPlayCard,
private val iamReadyToPlay: IamReadyToPlay,
private val iWantToJoinTheGame: IWantToJoinTheGame,
private val iCantPlay: ICantPlay,
) {
fun run(command: GameCommand): (version: Int) -> GameEvent =
when (command) {
is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
is ICantPlayCommand -> iCantPlay.run(command)
}
}
@@ -0,0 +1,139 @@
package eventDemo.business.command
import eventDemo.business.command.command.GameCommand
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.event.GameEvent
import eventDemo.business.notification.CommandErrorNotification
import eventDemo.business.notification.CommandSuccessNotification
import eventDemo.business.notification.Notification
import eventDemo.libs.command.CommandHandler
import eventDemo.libs.command.CommandRunnerController
import eventDemo.libs.event.EventHandlerImpl
import eventDemo.libs.event.VersionBuilder
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
/**
* Listen [GameCommand] on [GameEventBus], check the validity and execute an action.
*
* This action can be executing an action and produce a new [GameEvent] after verification.
*/
class GameCommandHandler(
eventBus: GameEventBus,
eventStore: GameEventStore,
versionBuilder: VersionBuilder,
runner: GameCommandActionRunner,
) {
private val logger = KotlinLogging.logger { }
private val eventHandler =
EventHandlerImpl(
eventBus,
eventStore,
versionBuilder,
)
private val commandHandler =
CommandHandler(
CommandRunnerController<GameCommand>(),
eventHandler,
) {
runner.run(it)
}
/**
* Subscribe to the [event bus][GameEventBus]
* to send success [notification][Notification] after save the [event][GameEvent].
*/
fun subscribeToBus(eventBus: GameEventBus) =
commandHandler.subscribeToBus(eventBus)
/**
* Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel],
* run the command and publish the generated [event][GameEvent] to the bus.
*
* It restricts to run only once a command.
*
* If the command fail, send an [error notification][CommandErrorNotification],
* if success, send a [success notification][CommandSuccessNotification]
*/
suspend fun handleIncomingPlayerCommands(
player: Player,
gameId: GameId,
incomingCommandChannel: ReceiveChannel<GameCommand>,
channelNotification: SendChannel<Notification>,
) {
for (command in incomingCommandChannel) {
handle(
player,
gameId,
command,
channelNotification.sendSuccess(command),
channelNotification.sendError(command),
)
}
}
/**
* Run the [command] and publish the generated [event][GameEvent] to the bus.
*
* It restricts to run only once a command.
*
* If the command fail, send an [error notification][CommandErrorNotification],
* if success, send a [success notification][CommandSuccessNotification]
*/
fun handle(
player: Player,
gameId: GameId,
command: GameCommand,
sendSuccess: () -> Unit,
sendError: (message: String) -> Unit,
) {
if (command.payload.aggregateId.id != gameId.id) {
logger.warn { "Handle command Refuse, the gameId of the command is not the same" }
sendError("The gameId in the command does not match with your game")
return
}
if (command.payload.player.id != player.id) {
logger.warn { "Handle command Refuse, the player of the command is not the same" }
sendError("You are not the author of this command")
return
}
commandHandler.handle(gameId, command) { _, error ->
if (error != null) {
sendError(error.message) // Business
} else {
sendSuccess()
}
}
}
}
private fun SendChannel<Notification>.sendSuccess(command: GameCommand): () -> Unit =
{
val logger = KotlinLogging.logger { }
CommandSuccessNotification(commandId = command.id)
.also { notification ->
withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) {
logger.debug { "Notification SUCCESS sent" }
trySend(notification)
}
}
}
private fun SendChannel<Notification>.sendError(command: GameCommand): (message: String) -> Unit =
{
val logger = KotlinLogging.logger { }
CommandErrorNotification(message = it, command = command)
.also { notification ->
withLoggingContext("notification" to notification.toString(), "command" to command.toString()) {
logger.warn { "Notification ERROR sent: ${notification.message}" }
trySend(notification)
}
}
}
@@ -0,0 +1,8 @@
package eventDemo.business.command.action
import eventDemo.libs.command.Command
import eventDemo.libs.event.Event
sealed interface CommandAction<C : Command, E : Event<*>> {
fun run(command: C): (version: Int) -> E
}
@@ -0,0 +1,36 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.ICantPlayCommand
import eventDemo.business.event.event.PlayerHavePassEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to perform an action to play a new card
*/
data class ICantPlay(
private val gameStateRepository: GameStateRepository,
) : CommandAction<ICantPlayCommand, PlayerHavePassEvent> {
override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
if (state.currentPlayerTurn != command.payload.player) {
throw CommandException("Its not your turn!")
}
val playableCards = state.playableCards(command.payload.player)
if (playableCards.isNotEmpty()) {
throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
}
val takenCard = state.deck.stack.first()
return { version ->
PlayerHavePassEvent(
aggregateId = command.payload.aggregateId,
player = command.payload.player,
takenCard = takenCard,
version = version,
)
}
}
}
@@ -0,0 +1,28 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.IWantToJoinTheGameCommand
import eventDemo.business.event.event.NewPlayerEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to perform an action to play a new card
*/
data class IWantToJoinTheGame(
private val gameStateRepository: GameStateRepository,
) : CommandAction<IWantToJoinTheGameCommand, NewPlayerEvent> {
override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
if (!state.isStarted) {
return {
NewPlayerEvent(
aggregateId = command.payload.aggregateId,
player = command.payload.player,
version = it,
)
}
} else {
throw CommandException("The game is already started")
}
}
}
@@ -0,0 +1,36 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.IWantToPlayCardCommand
import eventDemo.business.event.event.CardIsPlayedEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to perform an action to play a new card
*/
data class IWantToPlayCard(
private val gameStateRepository: GameStateRepository,
) : CommandAction<IWantToPlayCardCommand, CardIsPlayedEvent> {
override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
if (!state.isStarted) {
throw CommandException("The game is Not started")
}
if (state.currentPlayerTurn != command.payload.player) {
throw CommandException("Its not your turn!")
}
if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) {
throw CommandException("You cannot play this card")
}
return { version ->
CardIsPlayedEvent(
aggregateId = command.payload.aggregateId,
card = command.payload.card,
player = command.payload.player,
version = version,
)
}
}
}
@@ -0,0 +1,36 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.IamReadyToPlayCommand
import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to set as ready to play
*/
class IamReadyToPlay(
private val gameStateRepository: GameStateRepository,
) : CommandAction<IamReadyToPlayCommand, PlayerReadyEvent> {
@Throws(CommandException::class)
override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
val playerExist: Boolean = state.players.contains(command.payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player)
if (state.isStarted) {
throw CommandException("The game is already started")
} else if (!playerExist) {
throw CommandException("You are not in the game")
} else if (playerIsAlreadyReady) {
throw CommandException("You are already ready")
} else {
return { version: Int ->
PlayerReadyEvent(
aggregateId = command.payload.aggregateId,
player = command.payload.player,
version = version,
)
}
}
}
}
@@ -0,0 +1,17 @@
package eventDemo.business.command.command
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.Command
import kotlinx.serialization.Serializable
@Serializable
sealed interface GameCommand : Command {
val payload: Payload
@Serializable
sealed interface Payload {
val aggregateId: GameId
val player: Player
}
}
@@ -1,24 +1,22 @@
package eventDemo.contexts.game.application.command.models
package eventDemo.business.command.command
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
data class JoinTheGameCommand(
override val userId: UserId,
data class ICantPlayCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
@Serializable(with = GameIdSerializer::class)
override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload
}
@@ -0,0 +1,22 @@
package eventDemo.business.command.command
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
data class IWantToJoinTheGameCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload
}
@@ -0,0 +1,24 @@
package eventDemo.business.command.command
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
data class IWantToPlayCardCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
override val aggregateId: GameId,
override val player: Player,
val card: Card,
) : GameCommand.Payload
}
@@ -0,0 +1,22 @@
package eventDemo.business.command.command
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
/**
* A command to set as ready to play
*/
@Serializable
data class IamReadyToPlayCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload
}
@@ -1,6 +1,6 @@
package eventDemo.contexts.game.domain.game
package eventDemo.business.entity
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -23,7 +23,7 @@ sealed interface Card {
Green,
}
sealed interface CardWithColor : Card {
sealed interface ColorCard : Card {
val color: Color
}
@@ -38,14 +38,11 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Card,
CardWithColor {
ColorCard {
init {
if (number > 9) error("Card number cannot be greater of 9")
if (number < 0) error("Card number cannot be lower of 0")
}
override fun toString(): String =
"Numeric Card $number $color"
}
sealed interface Special : Card
@@ -60,10 +57,7 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
CardWithColor {
override fun toString(): String =
"Revert Card $color"
}
ColorCard
sealed interface PassTurnCard : Card
@@ -77,11 +71,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
CardWithColor,
PassTurnCard {
override fun toString(): String =
"Pass Card $color"
}
ColorCard,
PassTurnCard
/**
* A play card to force the next player to take 2 card and pass the turn.
@@ -93,13 +84,10 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
CardWithColor,
PassTurnCard {
override fun toString(): String =
"Plus2 Card $color"
}
ColorCard,
PassTurnCard
sealed interface CardWith4Color : Card
sealed interface AllColorCard : Card
/**
* A play card to force the next player to take 4 card and pass the turn.
@@ -110,11 +98,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
CardWith4Color,
PassTurnCard {
override fun toString(): String =
"Plus4 Card"
}
AllColorCard,
PassTurnCard
/**
* A play card to change the color.
@@ -125,8 +110,5 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
CardWith4Color {
override fun toString(): String =
"Change color Card"
}
AllColorCard
}
@@ -0,0 +1,133 @@
package eventDemo.business.entity
import kotlinx.serialization.Serializable
@Serializable
data class Deck(
val stack: Stack = Stack(),
val discard: Discard = Discard(),
val playersHands: PlayersHands = PlayersHands(),
) {
constructor(players: Set<Player>) :
this(playersHands = PlayersHands(players))
fun shuffle(): Deck =
copy(stack = stack.shuffle())
fun placeFirstCardOnDiscard(): Deck {
val takenCard = stack.first()
return copy(
stack = stack - takenCard,
discard = discard + takenCard,
)
}
fun takeOneCardFromStackTo(player: Player): Deck =
takeOne().let { (deck, newPlayerCard) ->
deck.copy(
playersHands = deck.playersHands.addCard(player, newPlayerCard),
)
}
fun putOneCardFromHand(
player: Player,
card: Card,
): Deck =
run {
// Validate parameters
val playerHand =
playersHands.getHand(player)
?: error("No player on this game")
if (playerHand.none { it == card }) {
error("No card exist on the player hand")
}
}.let {
copy(
discard = discard + card,
playersHands = playersHands.removeCard(player, card),
)
}
fun playerHasNoCardLeft(): List<Player.PlayerId> =
playersHands
.filter { (playerId, hand) -> hand.isEmpty() }
.map { (playerId, hand) -> playerId }
private fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
return Pair(copy(stack = newStack), takenCards)
}
private fun takeOne(): Pair<Deck, Card> =
take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
companion object {
fun newWithoutPlayers(): 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 {
it + (1..4).map { Card.Plus4Card() }
}.toStack()
.let { Deck(it) }
}
}
fun Deck.initHands(
players: Set<Player>,
handSize: Int = 7,
): Deck {
// Copy cards from stack to the player hands
val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
val hand = acc.stack.take(handSize)
val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
copy(
stack = newStack,
playersHands = acc.playersHands.addCards(player, hand),
)
}
}
@JvmInline
@Serializable
value class Stack(
private val cards: Set<Card> = emptySet(),
) : Set<Card> by cards {
operator fun plus(card: Card): Stack =
cards.plus(card).toStack()
operator fun minus(card: Card): Stack =
cards.minus(card).toStack()
fun shuffle(): Stack =
shuffled().toStack()
}
fun List<Card>.toStack(): Stack =
Stack(this.toSet())
fun Set<Card>.toStack(): Stack =
Stack(this)
@JvmInline
@Serializable
value class Discard(
private val cards: Set<Card> = emptySet(),
) : Set<Card> by cards {
operator fun plus(card: Card): Discard =
cards.plus(card).toDiscard()
operator fun minus(card: Card): Discard =
cards.minus(card).toDiscard()
}
fun List<Card>.toDiscard(): Discard =
Discard(this.toSet())
fun Set<Card>.toDiscard(): Discard =
Discard(this)
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.domain.game
package eventDemo.business.entity
import eventDemo.libs.eventSource.AggregateId
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.configuration.serializer.GameIdSerializer
import eventDemo.libs.event.AggregateId
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,9 +9,8 @@ import java.util.UUID
* An [AggregateId] for a game.
*/
@JvmInline
@Serializable
@Serializable(with = GameIdSerializer::class)
value class GameId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
override fun toString(): String =
@@ -0,0 +1,29 @@
package eventDemo.business.entity
import eventDemo.configuration.serializer.PlayerIdSerializer
import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.event.AggregateId
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()),
) {
constructor(id: String, name: String) : this(
name,
PlayerId(UUID.fromString(id)),
)
@Serializable
@JvmInline
value class PlayerId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
override fun toString(): String =
id.toString()
}
}
@@ -0,0 +1,50 @@
package eventDemo.business.entity
import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class PlayersHands(
private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
) : Map<Player.PlayerId, List<Card>> by map {
constructor(players: Set<Player>) :
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
fun getHand(player: Player): List<Card>? =
this[player.id]
fun removeCard(
player: Player,
card: Card,
): PlayersHands =
mapValues { (playerId, cards) ->
if (playerId == player.id) {
if (!cards.contains(card)) error("The hand no contain the card")
cards - card
} else {
cards
}
}.toPlayersHands()
fun addCard(
player: Player,
newCard: Card,
): PlayersHands =
addCards(player, listOf(newCard))
fun addCards(
player: Player,
newCards: List<Card>,
): PlayersHands =
mapValues { (p, cards) ->
if (p == player.id) {
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
cards + newCards
} else {
cards
}
}.toPlayersHands()
}
fun Map<Player.PlayerId, List<Card>>.toPlayersHands(): PlayersHands =
PlayersHands(this)
@@ -0,0 +1,6 @@
package eventDemo.business.event
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus
interface GameEventBus : Bus<GameEvent>
@@ -0,0 +1,16 @@
package eventDemo.business.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventHandler
import eventDemo.libs.event.EventHandlerImpl
import eventDemo.libs.event.VersionBuilder
/**
* Handle the event to dispatch it to store, bus and projections builders
*/
class GameEventHandler(
private val eventBus: GameEventBus,
private val eventStore: GameEventStore,
private val versionBuilder: VersionBuilder,
) : EventHandler<GameEvent, GameId> by EventHandlerImpl(eventBus, eventStore, versionBuilder)
@@ -0,0 +1,7 @@
package eventDemo.business.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventStore
interface GameEventStore : EventStore<GameEvent, GameId>
@@ -0,0 +1,26 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [GameEvent] to represent a played card.
*/
@Serializable
data class CardIsPlayedEvent(
override val aggregateId: GameId,
val card: Card,
override val player: Player,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,16 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.libs.event.Event
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [Event] of a Game.
*/
@Serializable
sealed interface GameEvent : Event<GameId> {
override val eventId: UUID
override val aggregateId: GameId
override val version: Int
}
@@ -0,0 +1,52 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Deck
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.entity.initHands
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when all players are ready.
*/
@Serializable
data class GameStartedEvent(
override val aggregateId: GameId,
val firstPlayer: Player,
val deck: Deck,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
companion object {
fun new(
id: GameId,
players: Set<Player>,
version: Int,
shuffleIsDisabled: Boolean = isDisabled,
): GameStartedEvent =
GameStartedEvent(
aggregateId = id,
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck =
Deck
.newWithoutPlayers()
.let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players)
.placeFirstCardOnDiscard(),
version = version,
)
}
}
private var isDisabled = false
internal fun disableShuffleDeck() {
isDisabled = true
}
@@ -0,0 +1,23 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [GameEvent] to represent a new player joining the game.
*/
@Serializable
data class NewPlayerEvent(
override val aggregateId: GameId,
val player: Player,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,9 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Player
import kotlinx.serialization.Serializable
@Serializable
sealed interface PlayerActionEvent : GameEvent {
val player: Player
}
@@ -0,0 +1,26 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player chose a color.
*/
@Serializable
data class PlayerChoseColorEvent(
override val aggregateId: GameId,
override val player: Player,
val color: Card.Color,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,26 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player can play.
*/
@Serializable
data class PlayerHavePassEvent(
override val aggregateId: GameId,
override val player: Player,
val takenCard: Card,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,23 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player is ready.
*/
@Serializable
data class PlayerReadyEvent(
override val aggregateId: GameId,
val player: Player,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,23 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player is ready.
*/
@Serializable
data class PlayerWinEvent(
override val aggregateId: GameId,
val player: Player,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,8 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.GameId
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable
@Serializable
sealed interface GameProjection : Projection<GameId>
@@ -0,0 +1,5 @@
package eventDemo.business.event.projection
import eventDemo.libs.bus.Bus
interface GameProjectionBus : Bus<GameProjection>
@@ -1,7 +1,8 @@
package eventDemo.contexts.game.infrastructure.persistence.projections.models
package eventDemo.business.event.projection
import eventDemo.contexts.game.domain.game.GameId
import eventDemo.contexts.game.domain.game.Player
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable
/**
@@ -9,10 +10,11 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class GameList(
val aggregateId: GameId,
override val aggregateId: GameId,
override val lastEventVersion: Int = 0,
val status: Status = Status.OPENING,
val players: Set<Player> = emptySet(),
val winners: Set<Player.PlayerId> = emptySet(),
val winners: Set<Player> = emptySet(),
) : GameProjection {
enum class Status {
OPENING,
@@ -0,0 +1,51 @@
package eventDemo.business.event.projection
import eventDemo.business.event.event.CardIsPlayedEvent
import eventDemo.business.event.event.GameEvent
import eventDemo.business.event.event.GameStartedEvent
import eventDemo.business.event.event.NewPlayerEvent
import eventDemo.business.event.event.PlayerChoseColorEvent
import eventDemo.business.event.event.PlayerHavePassEvent
import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.event.PlayerWinEvent
fun GameList.apply(event: GameEvent): GameList =
when (event) {
is NewPlayerEvent -> {
copy(
players = players + event.player,
status = GameList.Status.OPENING,
)
}
is GameStartedEvent -> {
copy(
status = GameList.Status.IS_STARTED,
)
}
is PlayerWinEvent -> {
copy(
winners = winners + event.player,
status = GameList.Status.FINISH,
)
}
is CardIsPlayedEvent -> {
this
}
is PlayerChoseColorEvent -> {
this
}
is PlayerHavePassEvent -> {
this
}
is PlayerReadyEvent -> {
this
}
}.copy(
lastEventVersion = event.version,
)
@@ -0,0 +1,5 @@
package eventDemo.business.event.projection
interface GameListRepository {
fun getList(): List<GameList>
}
@@ -0,0 +1,182 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.Card
import eventDemo.business.entity.Deck
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable
/**
* This [projection][Projection] is used for manage a game and theirs [card][Card]
*/
@Serializable
data class GameState(
override val aggregateId: GameId,
override val lastEventVersion: Int = 0,
val players: Set<Player> = emptySet(),
val currentPlayerTurn: Player? = null,
val lastCardPlayer: Player? = null,
val colorOnCurrentStack: Card.Color? = null,
val direction: Direction = Direction.CLOCKWISE,
val readyPlayers: Set<Player> = emptySet(),
val deck: Deck = Deck(players),
val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(),
val lastEvent: GameEvent? = null,
) : GameProjection {
enum class Direction {
CLOCKWISE,
COUNTER_CLOCKWISE,
;
fun revert(): Direction =
if (this === CLOCKWISE) {
COUNTER_CLOCKWISE
} else {
CLOCKWISE
}
}
val cardOnCurrentStack: Card? = deck.discard.lastOrNull()
val isReady: Boolean get() {
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
}
private val currentPlayerIndex: Int? get() {
val i = players.indexOf(currentPlayerTurn)
return if (i == -1) {
null
} else {
i
}
}
private fun nextPlayerIndex(direction: Direction): Int {
if (players.isEmpty()) return 0
return if (direction == Direction.CLOCKWISE) {
sidePlayerIndexClockwise
} else {
sidePlayerIndexCounterClockwise
}
}
fun nextPlayer(direction: Direction): Player =
players.elementAt(nextPlayerIndex(direction))
private val sidePlayerIndexClockwise: Int by lazy {
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) + 1) % players.size
}
}
private val sidePlayerIndexCounterClockwise: Int by lazy {
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) - 1) % players.size
}
}
val nextPlayerTurn: Player? by lazy {
if (players.isEmpty()) {
null
} else {
nextPlayer(direction)
}
}
private val Player.currentIndex: Int get() = players.indexOf(this)
fun Player.playerDiffIndex(nextPlayer: Player): Int =
if (direction == Direction.CLOCKWISE) {
nextPlayer.currentIndex + this.currentIndex
} else {
nextPlayer.currentIndex - this.currentIndex
}.let { it % players.size }
val Player.cardOnBoardIsForYou: Boolean get() {
if (lastCardPlayer == null) error("No card")
return this.playerDiffIndex(lastCardPlayer) == 1
}
fun playableCards(player: Player): List<Card> =
deck
.playersHands
.getHand(player)
?.filter { canBePlayThisCard(player, it) }
?: emptyList()
fun playerHasNoCardLeft(): List<Player> =
deck.playerHasNoCardLeft().map { playerId ->
players.find { it.id == playerId } ?: error("inconsistency detected between players")
}
fun canBePlayThisCard(
player: Player,
card: Card,
): Boolean {
val cardOnBoard = cardOnCurrentStack ?: return false
return when (cardOnBoard) {
is Card.NumericCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.ReverseCard -> {
when (card) {
is Card.ReverseCard -> true
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.PassCard -> {
if (player.cardOnBoardIsForYou) {
false
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
}
is Card.ChangeColorCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
is Card.Plus2Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
true
} else {
when (card) {
is Card.Plus2Card -> true
else -> false
}
}
}
is Card.Plus4Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
}
}
}
}
@@ -0,0 +1,116 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.Card
import eventDemo.business.event.event.CardIsPlayedEvent
import eventDemo.business.event.event.GameEvent
import eventDemo.business.event.event.GameStartedEvent
import eventDemo.business.event.event.NewPlayerEvent
import eventDemo.business.event.event.PlayerActionEvent
import eventDemo.business.event.event.PlayerChoseColorEvent
import eventDemo.business.event.event.PlayerHavePassEvent
import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.event.PlayerWinEvent
import io.github.oshai.kotlinlogging.KotlinLogging
fun GameState.apply(event: GameEvent): GameState =
this.let { state ->
val logger = KotlinLogging.logger { }
if (event is PlayerActionEvent) {
if (state.currentPlayerTurn != event.player) {
logger.atError {
message = "Inconsistent player turn"
payload =
mapOf(
"CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
"Player" to event.player,
)
}
}
}
when (event) {
is CardIsPlayedEvent -> {
val nextDirectionAfterPlay =
when (event.card) {
is Card.ReverseCard -> state.direction.revert()
else -> state.direction
}
val color =
when (event.card) {
is Card.ColorCard -> event.card.color
is Card.AllColorCard -> null
}
val currentPlayerAfterThePlay =
if (event.card is Card.AllColorCard) {
state.currentPlayerTurn
} else {
state.nextPlayer(nextDirectionAfterPlay)
}
state.copy(
currentPlayerTurn = currentPlayerAfterThePlay,
direction = nextDirectionAfterPlay,
colorOnCurrentStack = color,
lastCardPlayer = event.player,
deck = state.deck.putOneCardFromHand(event.player, event.card),
)
}
is NewPlayerEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
players = state.players + event.player,
)
}
is PlayerReadyEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
readyPlayers = state.readyPlayers + event.player,
)
}
is PlayerHavePassEvent -> {
if (event.takenCard != state.deck.stack.first()) {
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
}
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
deck = state.deck.takeOneCardFromStackTo(event.player),
)
}
is PlayerChoseColorEvent -> {
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
colorOnCurrentStack = event.color,
)
}
is GameStartedEvent -> {
state.copy(
colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
lastCardPlayer = null,
currentPlayerTurn = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
is PlayerWinEvent -> {
state.copy(
playerWins = state.playerWins + event.player,
)
}
}.copy(
lastEventVersion = event.version,
lastEvent = event,
)
}
@@ -0,0 +1,7 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.GameId
interface GameStateRepository {
fun get(gameId: GameId): GameState
}
@@ -0,0 +1,149 @@
package eventDemo.business.event.projection.projectionListener
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.event.event.CardIsPlayedEvent
import eventDemo.business.event.event.GameStartedEvent
import eventDemo.business.event.event.NewPlayerEvent
import eventDemo.business.event.event.PlayerChoseColorEvent
import eventDemo.business.event.event.PlayerHavePassEvent
import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.event.PlayerWinEvent
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.notification.ItsTheTurnOfNotification
import eventDemo.business.notification.Notification
import eventDemo.business.notification.PlayerAsJoinTheGameNotification
import eventDemo.business.notification.PlayerAsPlayACardNotification
import eventDemo.business.notification.PlayerHavePassNotification
import eventDemo.business.notification.PlayerWasChoseTheCardColorNotification
import eventDemo.business.notification.PlayerWasReadyNotification
import eventDemo.business.notification.PlayerWinNotification
import eventDemo.business.notification.TheGameWasStartedNotification
import eventDemo.business.notification.WelcomeToTheGameNotification
import eventDemo.business.notification.YourNewCardNotification
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
class PlayerNotificationListener(
private val projectionBus: GameProjectionBus,
) {
private val logger = KotlinLogging.logger {}
/**
* Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification]
*/
fun startListening(
currentPlayer: Player,
gameId: GameId,
outgoingNotification: (Notification) -> Unit,
): AutoCloseable {
return projectionBus.subscribe { currentState ->
if (currentState !is GameState) return@subscribe
if (currentState.aggregateId != gameId) return@subscribe
withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) {
fun Notification.send() {
withLoggingContext("notification" to this.toString()) {
if (currentState.players.contains(currentPlayer)) {
// Only notify players who have already joined the game.
outgoingNotification(this)
logger.info { "Notification was SEND" }
} else {
// Rare use case, when a connexion is created with the channel,
// but the player was not already join in the game
logger.warn { "Notification was SKIP, no player on the game" }
}
}
}
fun sendNextTurnNotif() =
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
val event =
currentState.lastEvent
?: error("No last event in the GameState projection")
when (event) {
is NewPlayerEvent -> {
if (currentPlayer != event.player) {
PlayerAsJoinTheGameNotification(
player = event.player,
).send()
} else {
WelcomeToTheGameNotification(
players = currentState.players,
).send()
}
}
is CardIsPlayedEvent -> {
if (currentPlayer != event.player) {
PlayerAsPlayACardNotification(
player = event.player,
card = event.card,
).send()
}
if (event.card !is Card.AllColorCard) {
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
}
}
is GameStartedEvent -> {
TheGameWasStartedNotification(
hand =
event.deck.playersHands.getHand(currentPlayer)
?: error("You are not in the game"),
).send()
sendNextTurnNotif()
}
is PlayerChoseColorEvent -> {
if (currentPlayer != event.player) {
PlayerWasChoseTheCardColorNotification(
player = event.player,
color = event.color,
).send()
}
sendNextTurnNotif()
}
is PlayerHavePassEvent -> {
if (currentPlayer == event.player) {
YourNewCardNotification(
card = event.takenCard,
).send()
} else {
PlayerHavePassNotification(
player = event.player,
).send()
}
sendNextTurnNotif()
}
is PlayerReadyEvent -> {
if (currentPlayer != event.player) {
PlayerWasReadyNotification(
player = event.player,
).send()
}
}
is PlayerWinEvent -> {
PlayerWinNotification(
player = event.player,
).send()
}
}
}
}
}
}
@@ -0,0 +1,75 @@
package eventDemo.business.event.projection.projectionListener
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventHandler
import eventDemo.business.event.event.GameStartedEvent
import eventDemo.business.event.event.PlayerWinEvent
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.libs.event.projection.Projection
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import java.util.concurrent.ConcurrentSkipListSet
class ReactionListener(
private val eventHandler: GameEventHandler,
) {
companion object Config {
val registeredListeners = ConcurrentSkipListSet<GameProjectionBus>()
}
private val logger = KotlinLogging.logger { }
fun subscribeToBus(projectionBus: GameProjectionBus) {
if (registeredListeners.add(projectionBus)) {
projectionBus.subscribe { projection: Projection<GameId> ->
if (projection !is GameState) return@subscribe
withLoggingContext("projection" to projection.toString()) {
sendStartGameEvent(projection)
sendWinnerEvent(projection)
}
}
} else {
"${this::class.simpleName} is already init for this bus".let {
logger.error { it }
error(it)
}
}
}
private fun sendStartGameEvent(state: GameState) {
if (state.isReady && !state.isStarted) {
val reactionEvent =
eventHandler.handle(state.aggregateId) {
GameStartedEvent.new(
id = state.aggregateId,
players = state.players,
version = it,
)
}
logger.atInfo {
message = "Reaction event was Send"
payload = mapOf("reactionEvent" to reactionEvent)
}
}
}
private fun sendWinnerEvent(state: GameState) {
val winner = state.playerHasNoCardLeft().firstOrNull()
if (winner != null) {
val reactionEvent =
eventHandler.handle(state.aggregateId) {
PlayerWinEvent(
aggregateId = state.aggregateId,
player = winner,
version = it,
)
}
logger.atInfo {
message = "Reaction event was Send"
payload = mapOf("reactionEvent" to reactionEvent)
}
}
}
}
@@ -0,0 +1,15 @@
package eventDemo.business.notification
import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.command.Command
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CommandErrorNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val message: String,
val command: Command,
) : Notification,
CommandNotification
@@ -0,0 +1,3 @@
package eventDemo.business.notification
sealed interface CommandNotification : Notification
@@ -0,0 +1,14 @@
package eventDemo.business.notification
import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CommandSuccessNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val commandId: CommandId,
) : Notification,
CommandNotification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models
package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -1,6 +1,6 @@
package eventDemo.contexts.game.application.notification.models
package eventDemo.business.notification
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models
package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -1,8 +1,8 @@
package eventDemo.contexts.game.application.notification.models
package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Card
import eventDemo.contexts.game.domain.game.Player
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.business.entity.Card
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -10,6 +10,6 @@ import java.util.UUID
data class PlayerAsPlayACardNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId,
val player: Player,
val card: Card,
) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models
package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId,
val player: Player,
) : Notification
@@ -0,0 +1,15 @@
package eventDemo.business.notification
import eventDemo.business.entity.Card
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class PlayerWasChoseTheCardColorNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val player: Player,
val color: Card.Color,
) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models
package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player
import eventDemo.libs.serializer.UUIDSerializer
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId,
val player: Player,
) : Notification

Some files were not shown because too many files have changed in this diff Show More