Compare commits

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 5230 additions and 6056 deletions

No files matched your search

-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: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -31,7 +31,7 @@ jobs:
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Cache Gradle dependencies - name: Cache Gradle dependencies
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -48,16 +48,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
- name: Restore Gradle cache - name: Restore Gradle cache
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,65 +73,53 @@ jobs:
run: ./gradlew ktlintCheck run: ./gradlew ktlintCheck
- name: Publish ktlint report - name: Publish ktlint report
uses: yutailang0119/action-ktlint@v5 uses: yutailang0119/action-ktlint@v4
if: always() if: always()
with: with:
report-path: build/reports/ktlint/**/*.xml report-path: build/reports/ktlint/**/*.xml
continue-on-error: false continue-on-error: false
test: test:
needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install a pinned Docker Compose version - name: Set up JDK 21
run: | uses: actions/setup-java@v4
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
with: with:
path: ${{ env.GRADLE_CACHE_DIR }} distribution: 'temurin'
key: ${{ steps.cache-key-generator.outputs.key }} 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: | restore-keys: |
gradle-docker-${{ runner.os }}- gradle-${{ runner.os }}-
- name: Prepare cache directory permissions - name: Grant execute permission to Gradle wrapper
run: | run: chmod +x gradlew
mkdir -p "$GRADLE_CACHE_DIR"
chmod -R 777 "$GRADLE_CACHE_DIR"
- name: Run tests in Docker - name: Start CI Docker Compose services
run: docker compose -f docker/docker-compose-test.yaml run tests run: ./gradlew composeUp -Pci
- name: Shut down Docker services - name: Run tests
if: always() run: ./gradlew test -x composeUp --no-daemon
run: docker compose -f docker/docker-compose-test.yaml down -v
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: test-results name: test-results
path: build/reports/tests/test path: build/reports/tests/test
- name: Publish Test Report - name: Publish Test Report
uses: dorny/test-reporter@v3 uses: dorny/test-reporter@v1
if: always() if: always()
with: with:
name: JUnit Tests name: JUnit Tests
-1
View File
@@ -37,4 +37,3 @@ out/
/docker/.env /docker/.env
/docker/*.secret /docker/*.secret
*.hprof *.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) - [Installation](./doc/installation.md)
- [What's the demo for ?](#whats-the-demo-for-) - [What's the demo for ?](#whats-the-demo-for-)
- [What's in this demo](#whats-in-this-demo)
- [The stack](#the-stack) - [The stack](#the-stack)
- [Architecture](./doc/architecture.md) - [Architecture](./doc/architecture.md)
@@ -17,6 +18,31 @@ of different patterns and architectures.
- The CQRS pattern. - The CQRS pattern.
- With the Hexagonal architecture. - 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 The stack
--------- ---------
@@ -25,11 +51,11 @@ Language
Framework Framework
- Ktor - Ktor
- with Koin for Dependency Injection
Database Database
- Postgresql - Postgresql
- with Flyway - with Flyway
- Redis
- RabbitMQ - RabbitMQ
Infra Infra
+83 -35
View File
@@ -1,19 +1,22 @@
@file:Suppress("PropertyName")
import org.jlleitschuh.gradle.ktlint.KtlintExtension import org.jlleitschuh.gradle.ktlint.KtlintExtension
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version") val ktor_version: String by project
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version") val kotlin_version: String by project
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version") val kotlin_serialization_version: String by project
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version") val logback_version: String by project
val koinVersion: Provider<String> = providers.gradleProperty("koin_version") val koin_version: String by project
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version") val kotlin_logging_version: String by project
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version") val kotest_version: String by project
plugins { plugins {
application application
kotlin("jvm") version "2.1.21" kotlin("jvm") version "2.1.10"
id("io.ktor.plugin") version "3.5.1" id("io.ktor.plugin") version "3.1.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0" id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12"
} }
group = "io.github.flecomte" group = "io.github.flecomte"
@@ -26,7 +29,7 @@ application {
} }
configure<KtlintExtension> { configure<KtlintExtension> {
version.set("1.8.0") version.set("1.5.0")
} }
ktlint { ktlint {
reporters { reporters {
@@ -46,17 +49,63 @@ java {
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnitPlatform() 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 dockerCompose {
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the val composeFile =
// pre-installed Instrumentation instance and skips dynamic attach. if (project.hasProperty("ci")) {
doFirst { // Use docker-compose-ci.yaml for the CI
val agentJar = "docker/docker-compose-ci.yaml"
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") } } else {
?: error("byte-buddy-agent jar not found on test classpath") // Use docker-compose-test.yaml for local tests
jvmArgs("-javaagent:$agentJar") "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 { dependencies {
@@ -75,24 +124,23 @@ dependencies {
implementation("io.ktor:ktor-server-data-conversion") implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation") implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth") implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}") implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}") implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}") implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}") implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") 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.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0") implementation("com.rabbitmq:amqp-client:5.25.0")
implementation("com.password4j:password4j:1.8.4")
// Force version of sub library (for security) // Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13") implementation("commons-codec:commons-codec:1.13")
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}") testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}") testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}") testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
testImplementation("io.mockk:mockk:1.14.11") testImplementation("io.mockk:mockk:1.13.17")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
} }
+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 Installation
============ ============
To run the stack in production: To run the stack:
```shell ```shell
docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d 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: Api url:
- [Backend API](http://api.traefik.me/) - [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/) - [PostgreSql](http://localhost:5432/)
- [Redis](http://localhost:6379/)
- [RabbitMQ](http://localhost:15672/) - [RabbitMQ](http://localhost:15672/)
Admin service URL: Admin service URL:
- [Træfik dashboard](http://traefik.traefik.me/) - [Træfik dashboard](http://traefik.traefik.me/)
- [Redis insight](http://insight.redis.traefik.me/)
- [pgAdmin](http://pgadmin.postgresql.traefik.me/) - [pgAdmin](http://pgadmin.postgresql.traefik.me/)
- [RabbitMQ management](http://management.rabbitmq.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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="668px" preserveAspectRatio="none" style="width:1329px;height:668px;" version="1.1" viewBox="0 0 1329 668" width="1329px" zoomAndPan="magnify"><defs/><g><!--class GameList--><g id="elem_GameList"><rect codeLine="5" fill="#F1F1F1" height="79.2031" id="GameList" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="110.8203" x="50.5" y="7"/><ellipse cx="72.7501" cy="29.291" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1.0;"/><path d="M75.5157,25.166 Q75.672,24.9473 75.8595,24.8379 Q76.047,24.7285 76.2657,24.7285 Q76.6407,24.7285 76.8751,24.9941 Q77.1095,25.2441 77.1095,25.8535 L77.1095,27.3066 Q77.1095,27.916 76.8751,28.1816 Q76.6407,28.4473 76.2657,28.4473 Q75.922,28.4473 75.7188,28.2441 Q75.5157,28.0566 75.4063,27.541 Q75.3595,27.1816 75.172,26.9941 Q74.8438,26.6191 74.2345,26.4004 Q73.6251,26.1816 73.0001,26.1816 Q72.2345,26.1816 71.5938,26.5098 Q70.9688,26.8379 70.4688,27.5879 Q69.9845,28.3379 69.9845,29.3691 L69.9845,30.4629 Q69.9845,31.6973 70.8751,32.5254 Q71.7657,33.3379 73.3595,33.3379 Q74.297,33.3379 74.9532,33.0879 Q75.3438,32.9316 75.7657,32.4941 Q76.0313,32.2285 76.172,32.1504 Q76.3282,32.0723 76.5313,32.0723 Q76.8595,32.0723 77.1095,32.3379 Q77.3751,32.5879 77.3751,32.9316 Q77.3751,33.2754 77.0313,33.6816 Q76.5313,34.2598 75.7345,34.5879 Q74.6563,35.041 73.3595,35.041 Q71.8438,35.041 70.6407,34.416 Q69.6563,33.916 68.9688,32.8535 Q68.2813,31.7754 68.2813,30.4941 L68.2813,29.3379 Q68.2813,28.0098 68.8907,26.8691 Q69.5157,25.7129 70.6095,25.1035 Q71.7032,24.4785 72.9376,24.4785 Q73.672,24.4785 74.3126,24.6504 Q74.9688,24.8066 75.5157,25.166 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="12" font-style="italic" lengthAdjust="spacing" textLength="66.709" x="86.3612" y="24.9492">&#171;Projection&#187;</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="59.9102" x="89.7606" y="43.0684">GameList</text><line style="stroke:#181818;stroke-width:0.5;" x1="51.5" x2="160.3203" y1="51.582" y2="51.582"/><ellipse cx="61.5" cy="66.3926" fill="none" rx="3" ry="3" style="stroke:#038048;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="84.8203" x="70.5" y="70.6895">status: Status</text><line style="stroke:#181818;stroke-width:0.5;" x1="51.5" x2="160.3203" y1="78.2031" y2="78.2031"/></g><!--class GameState--><g id="elem_GameState"><rect codeLine="8" fill="#F1F1F1" height="228.1719" id="GameState" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="197.9512" x="7" y="121.5"/><ellipse cx="66.3188" cy="143.791" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1.0;"/><path d="M69.0845,139.666 Q69.2407,139.4473 69.4282,139.3379 Q69.6157,139.2285 69.8345,139.2285 Q70.2095,139.2285 70.4438,139.4941 Q70.6782,139.7441 70.6782,140.3535 L70.6782,141.8066 Q70.6782,142.416 70.4438,142.6816 Q70.2095,142.9473 69.8345,142.9473 Q69.4907,142.9473 69.2876,142.7441 Q69.0845,142.5566 68.9751,142.041 Q68.9282,141.6816 68.7407,141.4941 Q68.4126,141.1191 67.8032,140.9004 Q67.1938,140.6816 66.5688,140.6816 Q65.8032,140.6816 65.1626,141.0098 Q64.5376,141.3379 64.0376,142.0879 Q63.5532,142.8379 63.5532,143.8691 L63.5532,144.9629 Q63.5532,146.1973 64.4438,147.0254 Q65.3345,147.8379 66.9282,147.8379 Q67.8657,147.8379 68.522,147.5879 Q68.9126,147.4316 69.3345,146.9941 Q69.6001,146.7285 69.7407,146.6504 Q69.897,146.5723 70.1001,146.5723 Q70.4282,146.5723 70.6782,146.8379 Q70.9438,147.0879 70.9438,147.4316 Q70.9438,147.7754 70.6001,148.1816 Q70.1001,148.7598 69.3032,149.0879 Q68.2251,149.541 66.9282,149.541 Q65.4126,149.541 64.2095,148.916 Q63.2251,148.416 62.5376,147.3535 Q61.8501,146.2754 61.8501,144.9941 L61.8501,143.8379 Q61.8501,142.5098 62.4595,141.3691 Q63.0845,140.2129 64.1782,139.6035 Q65.272,138.9785 66.5063,138.9785 Q67.2407,138.9785 67.8813,139.1504 Q68.5376,139.3066 69.0845,139.666 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="12" font-style="italic" lengthAdjust="spacing" textLength="66.709" x="88.8711" y="139.4492">&#171;Projection&#187;</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="70.8135" x="86.8188" y="157.5684">GameState</text><line style="stroke:#181818;stroke-width:0.5;" x1="8" x2="203.9512" y1="166.082" y2="166.082"/><ellipse cx="18" cy="180.8926" fill="none" rx="3" ry="3" style="stroke:#038048;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="130.7305" x="27" y="185.1895">players: List&lt;Player&gt;</text><ellipse cx="18" cy="199.5137" fill="none" rx="3" ry="3" style="stroke:#038048;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="159.5029" x="27" y="203.8105">currentPlayerTurn: PlayerLine truncated

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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="1460px" preserveAspectRatio="none" style="width:1185px;height:1460px;" version="1.1" viewBox="0 0 1185 1460" width="1185px" zoomAndPan="magnify"><defs/><g><!--cluster Legend--><g id="cluster_Legend"><path d="M897,6.0137 L947.7793,6.0137 A3.75,3.75 0 0 1 950.2793,8.5137 L957.2793,30.6348 L1176,30.6348 A2.5,2.5 0 0 1 1178.5,33.1348 L1178.5,207.5137 A2.5,2.5 0 0 1 1176,210.0137 L897,210.0137 A2.5,2.5 0 0 1 894.5,207.5137 L894.5,8.5137 A2.5,2.5 0 0 1 897,6.0137 " fill="none" style="stroke:#000000;stroke-width:1.5;"/><line style="stroke:#000000;stroke-width:1.5;" x1="894.5" x2="957.2793" y1="30.6348" y2="30.6348"/><text fill="#000000" font-family="sans-serif" font-size="14" font-weight="bold" lengthAdjust="spacing" textLength="49.7793" x="898.5" y="23.1211">Legend</text></g><g id="elem_Queries"><ellipse cx="957.5879" cy="59.315" fill="#7693C4" rx="46.5879" ry="16.3013" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="49.0205" x="930.0776" y="64.1187">Queries</text></g><g id="elem_Projections"><ellipse cx="1100.6745" cy="59.4601" fill="#AB64C9" rx="61.6745" ry="16.4464" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="69.2549" x="1063.0471" y="64.2638">Projections</text></g><g id="elem_Events"><ellipse cx="957.4528" cy="178.2499" fill="#5FAD56" rx="41.9528" ry="16.2362" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="42.7998" x="933.2413" y="183.0022">Events</text></g><!--entity User--><g id="elem_User"><ellipse cx="605.2793" cy="28.5137" fill="#F1F1F1" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M605.2793,36.5137 L605.2793,63.5137 M592.2793,44.5137 L618.2793,44.5137 M605.2793,63.5137 L592.2793,78.5137 M605.2793,63.5137 L618.2793,78.5137 " fill="none" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="29.5586" x="590.5" y="95.1211">User</text></g><!--entity Query--><g id="elem_Query"><ellipse cx="726.562" cy="50.0137" fill="#7693C4" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="714.562" x2="738.562" y1="64.0137" y2="64.0137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="38.124" x="707.5" y="81.1211">Query</text></g><!--entity Command--><g id="elem_Command"><ellipse cx="471.2896" cy="50.0137" fill="#5FAD56" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="459.2896" x2="483.2896" y1="64.0137" y2="64.0137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="64.5791" x="439" y="81.1211">Command</text></g><!--entity Event--><g id="elem_Event"><ellipse cx="243.3999" cy="530.5137" fill="#5FAD56" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="231.3999" x2="255.3999" y1="544.5137" y2="544.5137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="35.7998" x="225.5" y="561.6211">Event</text></g><!--entity Projection--><g id="elem_Projection"><ellipse cx="750.6274" cy="1410.0137" fill="#AB64C9" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="738.6274" x2="762.6274" y1="1424.0137" y2="1424.0137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="62.2549" x="719.5" y="1441.1211">Projection</text></g><!--entity Postgresql--><g id="elem_Postgresql"><path d="M83.5,905.0137 C83.5,895.0137 126.5723,895.0137 126.5723,895.0137 C126.5723,895.0137 169.6445,895.0137 169.6445,905.0137 L169.6445,932.6348 C169.6445,942.6348 126.5723,942.6348 126.5723,942.6348 C126.5723,942.6348 83.5,942.6348 83.5,932.6348 L83.5,905.0137 " fill="#F1F1F1" style="stroke:#181818;stroke-width:0.5;"/><path d="M83.5,905.0137 C83.5,915.0137 126.5723,915.0137 126.5723,915.0137 C126.5723,915.0137 169.6445,915.0137 169.6445,905.0137 " fill="none" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="66.1445" x="93.5" y="934.1211">Postgresql</text></g><!--entity Redis--><g id="elem_Redis"><path d="M462.5,1405.5137 C462.5,1395.5137 490.3965,1395.5137 490.3965,1395.5137 C490.3965,1395.5137 518.293,1395.5137 518.293,1405.5137 L518.293,1433.1348 C518.293,1443.1348 490.3965,1443.1348 490.3965,1443.1348 C490.3965,1443.1348 462.5,1443.1348 462.5,1433.1348 L462.5,1405.5137 " fill="#F1F1F1" style="stroke:#181818;stroke-width:0.5;"/><path d="M462.5,1405.5137 C462.5,1415.5137 490.3965,Line truncated

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 # 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 RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME=/home/gradle/cache_home ENV GRADLE_USER_HOME=/home/gradle/cache_home
COPY build.gradle.* gradle.properties /home/gradle/app/ COPY build.gradle.* gradle.properties /home/gradle/app/
@@ -7,7 +7,7 @@ WORKDIR /home/gradle/app
RUN gradle build -i -x check RUN gradle build -i -x check
# Stage 2: Build Application # 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 --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/src COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src WORKDIR /home/gradle/src
@@ -16,8 +16,8 @@ WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon RUN gradle buildFatJar --no-daemon
# Stage 3: Create the Runtime Image # Stage 3: Create the Runtime Image
FROM eclipse-temurin:21-jre-alpine AS runtime FROM amazoncorretto:21 AS runtime
EXPOSE 8080 EXPOSE 8080
RUN mkdir /app 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"] 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-app.yaml
- parts/docker-compose-tools.yaml - parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.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: include:
- path: - path:
- parts/docker-compose-databases.yaml - 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 - parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
+2 -2
View File
@@ -10,8 +10,8 @@ services:
condition: service_healthy condition: service_healthy
rabbitmq: rabbitmq:
condition: service_healthy condition: service_healthy
env_file: redis:
- ../.env.docker condition: service_healthy
labels: labels:
- "traefik.http.routers.api.rule=Host(`api.traefik.me`)" - "traefik.http.routers.api.rule=Host(`api.traefik.me`)"
- "traefik.http.services.api.loadbalancer.server.port=8080" - "traefik.http.services.api.loadbalancer.server.port=8080"
@@ -1,4 +1,8 @@
services: services:
redis:
ports:
- "6379:6379"
postgresql: postgresql:
ports: ports:
- "5432:5432" - "5432:5432"
+19 -2
View File
@@ -1,4 +1,12 @@
services: 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: flyway:
image: flyway/flyway image: flyway/flyway
command: migrate command: migrate
@@ -11,17 +19,20 @@ services:
restart: no restart: no
postgresql: postgresql:
image: postgres:18.4 image: postgres:17.4
command: postgres -c 'max_connections=500' command: postgres -c 'max_connections=500'
environment: environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
POSTGRES_USER: event-demo POSTGRES_USER: event-demo
secrets:
- postgresql_password
healthcheck: healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"] test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
interval: 1s interval: 1s
timeout: 5s timeout: 5s
retries: 10 retries: 10
volumes: volumes:
- postgresql_data:/var/lib/postgresql - postgresql_data:/var/lib/postgresql/data
rabbitmq: rabbitmq:
image: rabbitmq:4-management-alpine image: rabbitmq:4-management-alpine
@@ -36,6 +47,12 @@ services:
volumes: volumes:
- rabbitmq_data:/var/lib/rabbitmq/ - rabbitmq_data:/var/lib/rabbitmq/
secrets:
postgresql_password:
file: ../postgresql.secret
volumes: volumes:
redis_data:
redisinsight_data:
postgresql_data: postgresql_data:
rabbitmq_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: pgadmin:
image: dpage/pgadmin4 image: dpage/pgadmin4
environment: 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: volumes:
- pgadmin_data:/var/lib/pgadmin - pgadmin_data:/var/lib/pgadmin
configs:
- source: servers_json
target: /pgadmin4/servers.json
labels: labels:
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)" - "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
- "traefik.http.services.pgadmin.loadbalancer.server.port=80" - "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: rabbitmq:
labels: labels:
- "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)" - "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)"
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management" - "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672" - "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
configs: secrets:
servers_json: pgadmin_password:
content: | file: ../pgadmin.secret
{
"Servers": {
"1": {
"Name": "Event demo",
"Group": "Servers",
"Host": "postgresql",
"Port": 5432,
"MaintenanceDB": "event-demo",
"Username": "event-demo",
"PassFile": "/pgpass",
"SSLMode": "prefer"
}
}
}
volumes: volumes:
pgadmin_data: pgadmin_data:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: traefik:3.7.9 image: traefik:3.3.4
command: command:
- "--api.insecure=true" - "--api.insecure=true"
- "--api.dashboard=true" - "--api.dashboard=true"
+7 -7
View File
@@ -1,9 +1,9 @@
ktor_version=3.5.1 ktor_version=3.1.1
kotlin_version=2.1.21 kotlin_version=2.1.10
kotlin_serialization_version=1.11.0 kotlin_serialization_version=1.8.0
logback_version=1.5.38 logback_version=1.5.17
koin_version=4.2.1 koin_version=4.0.2
kotlin_logging_version=8.0.4 kotlin_logging_version=7.0.5
kotest_version=6.2.2 kotest_version=6.0.0.M2
kotlin.code.style=official kotlin.code.style=official
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
@@ -1,5 +1,4 @@
create schema game; create table event_stream (
create table game.game_event_stream (
id uuid not null primary key, id uuid not null primary key,
aggregate_id uuid not null, aggregate_id uuid not null,
version int 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)
);
+35 -639
View File
@@ -1,264 +1,58 @@
openapi: "3.0.3" openapi: "3.0.3"
info: info:
title: "event_demo API" title: "event_demo API"
description: | description: "event_demo API"
API for the event_demo project: a small event-sourced Uno-like card game. version: "1.0.0"
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"
servers: servers:
- url: "https://event_demo" - 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: paths:
"/login/{username}": "/game/{id}/card/last":
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":
get: get:
tags: [Games] description: get the last card played
summary: List all known games
description: Returns up to the 100 most recent games (pagination is not yet exposed on this route).
responses: responses:
200: 200:
description: The list of games description: The last card
content: content:
application/json: application/json:
schema: schema:
type: array $ref: "#/components/schemas/Card"
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: 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: schemas:
LoginResponse: Card:
type: object oneOf:
required: [token] - $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: properties:
token: number:
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 type: integer
invalidParams: color:
type: array $ref: "#/components/schemas/CardColor"
items: ReverseCard:
$ref: "#/components/schemas/InvalidParam"
InvalidParam:
type: object
required: [name, reason]
properties: properties:
name: color:
type: string $ref: "#/components/schemas/CardColor"
reason: PassCard:
type: string
PlayerId:
type: string
format: uuid
Player:
type: object
required: [name, userId, hand, id]
properties: properties:
name: color:
type: string $ref: "#/components/schemas/CardColor"
userId: Plus2Card:
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]
properties: properties:
aggregateId: color:
type: string $ref: "#/components/schemas/CardColor"
format: uuid Plus4Card:
status: properties:
$ref: "#/components/schemas/GameStatus" nextColor:
players: $ref: "#/components/schemas/CardColor"
type: array ChangeColorCard:
items: properties:
$ref: "#/components/schemas/Player" nextColor:
winners: $ref: "#/components/schemas/CardColor"
type: array
items:
$ref: "#/components/schemas/PlayerId"
GameStatus:
type: string
enum:
- OPENING
- IS_STARTED
- FINISH
- CANCELED
CardColor: CardColor:
type: string type: string
enum: enum:
@@ -266,401 +60,3 @@ components:
- Red - Red
- Yellow - Yellow
- Green - 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.business.event.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory import eventDemo.libs.bus.BusInMemory
import java.util.UUID 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 com.rabbitmq.client.ConnectionFactory
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.business.event.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json 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.business.event.projection.GameProjection
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory import eventDemo.libs.bus.BusInMemory
import java.util.UUID 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 com.rabbitmq.client.ConnectionFactory
import eventDemo.contexts.game.application.ports.GameProjectionBus import eventDemo.business.event.projection.GameProjection
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json 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.resources.Resource
import io.ktor.server.auth.authenticate import io.ktor.server.auth.authenticate
import io.ktor.server.resources.get import io.ktor.server.resources.get
@@ -15,7 +15,7 @@ class Games
/** /**
* API routes to show all games. * API routes to show all games.
*/ */
fun Route.gamesListRoute(gameListRepository: GameListRepository) { fun Route.readGamesList(gameListRepository: GameListRepository) {
authenticate { authenticate {
// Read the last played card on the game. // Read the last played card on the game.
get<Games> { 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( class CommandException(
override val message: String, 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.business.entity.GameId
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
*/ */
@Serializable @Serializable
data class JoinTheGameCommand( data class ICantPlayCommand(
override val userId: UserId,
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
@Serializable(with = GameIdSerializer::class)
override val aggregateId: GameId, override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload ) : 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.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -23,7 +23,7 @@ sealed interface Card {
Green, Green,
} }
sealed interface CardWithColor : Card { sealed interface ColorCard : Card {
val color: Color val color: Color
} }
@@ -38,14 +38,11 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Card, ) : Card,
CardWithColor { ColorCard {
init { init {
if (number > 9) error("Card number cannot be greater of 9") if (number > 9) error("Card number cannot be greater of 9")
if (number < 0) error("Card number cannot be lower of 0") if (number < 0) error("Card number cannot be lower of 0")
} }
override fun toString(): String =
"Numeric Card $number $color"
} }
sealed interface Special : Card sealed interface Special : Card
@@ -60,10 +57,7 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWithColor { ColorCard
override fun toString(): String =
"Revert Card $color"
}
sealed interface PassTurnCard : Card sealed interface PassTurnCard : Card
@@ -77,11 +71,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWithColor, ColorCard,
PassTurnCard { PassTurnCard
override fun toString(): String =
"Pass Card $color"
}
/** /**
* A play card to force the next player to take 2 card and pass the turn. * A play card to force the next player to take 2 card and pass the turn.
@@ -93,13 +84,10 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWithColor, ColorCard,
PassTurnCard { PassTurnCard
override fun toString(): String =
"Plus2 Card $color"
}
sealed interface CardWith4Color : Card sealed interface AllColorCard : Card
/** /**
* A play card to force the next player to take 4 card and pass the turn. * A play card to force the next player to take 4 card and pass the turn.
@@ -110,11 +98,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWith4Color, AllColorCard,
PassTurnCard { PassTurnCard
override fun toString(): String =
"Plus4 Card"
}
/** /**
* A play card to change the color. * A play card to change the color.
@@ -125,8 +110,5 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWith4Color { AllColorCard
override fun toString(): String =
"Change color Card"
}
} }
@@ -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.configuration.serializer.GameIdSerializer
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.libs.event.AggregateId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,9 +9,8 @@ import java.util.UUID
* An [AggregateId] for a game. * An [AggregateId] for a game.
*/ */
@JvmInline @JvmInline
@Serializable @Serializable(with = GameIdSerializer::class)
value class GameId( value class GameId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId { ) : AggregateId {
override fun toString(): String = 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.business.entity.GameId
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -9,10 +10,11 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class GameList( data class GameList(
val aggregateId: GameId, override val aggregateId: GameId,
override val lastEventVersion: Int = 0,
val status: Status = Status.OPENING, val status: Status = Status.OPENING,
val players: Set<Player> = emptySet(), val players: Set<Player> = emptySet(),
val winners: Set<Player.PlayerId> = emptySet(), val winners: Set<Player> = emptySet(),
) : GameProjection { ) : GameProjection {
enum class Status { enum class Status {
OPENING, 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.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID 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 kotlinx.serialization.Serializable
import java.util.UUID 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.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID 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.business.entity.Card
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -10,6 +10,6 @@ import java.util.UUID
data class PlayerAsPlayACardNotification( data class PlayerAsPlayACardNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
val card: Card, val card: Card,
) : Notification ) : 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.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerHavePassNotification( data class PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
) : Notification ) : 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.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerWasReadyNotification( data class PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
) : Notification ) : Notification
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more