Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256524f63e
|
@@ -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/
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
* text=auto
|
|
||||||
* eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.png binary
|
|
||||||
*.jar binary
|
|
||||||
gradlew.bat eol=crlf
|
|
||||||
gradlew text eol=lf
|
|
||||||
+34
-50
@@ -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
|
||||||
@@ -70,74 +70,58 @@ jobs:
|
|||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
# Path scoped to :backend on purpose: :composeApp applies the Android Gradle plugin,
|
run: ./gradlew ktlintCheck
|
||||||
# which needs an Android SDK to even configure. Keeping every gradlew invocation on a
|
|
||||||
# fully-qualified project path (with org.gradle.configureondemand=true) lets CI skip
|
|
||||||
# configuring :composeApp entirely, so no Android SDK setup is needed on this runner.
|
|
||||||
run: ./gradlew :backend: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: backend/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: backend/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
|
||||||
path: backend/build/test-results/test/TEST-*.xml
|
path: build/test-results/test/TEST-*.xml
|
||||||
reporter: java-junit
|
reporter: java-junit
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
.gradle
|
.gradle
|
||||||
.kotlin/
|
|
||||||
build/
|
build/
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/build/
|
!**/src/test/**/build/
|
||||||
|
|
||||||
### Android / KMP ###
|
|
||||||
local.properties
|
|
||||||
captures/
|
|
||||||
.cxx/
|
|
||||||
|
|
||||||
### STS ###
|
### STS ###
|
||||||
.apt_generated
|
.apt_generated
|
||||||
.classpath
|
.classpath
|
||||||
@@ -43,4 +37,3 @@ out/
|
|||||||
/docker/.env
|
/docker/.env
|
||||||
/docker/*.secret
|
/docker/*.secret
|
||||||
*.hprof
|
*.hprof
|
||||||
/.gradle-docker-cache/
|
|
||||||
|
|||||||
-32
@@ -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>"</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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import org.jlleitschuh.gradle.ktlint.KtlintExtension
|
|
||||||
|
|
||||||
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
|
|
||||||
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
|
|
||||||
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
|
|
||||||
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
|
|
||||||
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
|
|
||||||
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
|
|
||||||
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
application
|
|
||||||
kotlin("jvm")
|
|
||||||
id("io.ktor.plugin") version "3.5.1"
|
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
group = "io.github.flecomte"
|
|
||||||
|
|
||||||
application {
|
|
||||||
mainClass.set("eventDemo.ApplicationKt")
|
|
||||||
|
|
||||||
val isDevelopment: Boolean = project.ext.has("development")
|
|
||||||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
|
||||||
}
|
|
||||||
|
|
||||||
configure<KtlintExtension> {
|
|
||||||
version.set("1.8.0")
|
|
||||||
}
|
|
||||||
ktlint {
|
|
||||||
reporters {
|
|
||||||
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
java {
|
|
||||||
toolchain {
|
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
|
||||||
useJUnitPlatform()
|
|
||||||
jvmArgs("-Djdk.attach.allowAttachSelf=true", "-XX:+EnableDynamicAgentLoading")
|
|
||||||
// Dynamic self-attach (used by MockK/ByteBuddy) times out in Docker containers because the
|
|
||||||
// SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
|
|
||||||
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
|
|
||||||
// pre-installed Instrumentation instance and skips dynamic attach.
|
|
||||||
doFirst {
|
|
||||||
val agentJar =
|
|
||||||
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
|
|
||||||
?: error("byte-buddy-agent jar not found on test classpath")
|
|
||||||
jvmArgs("-javaagent:$agentJar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":shared"))
|
|
||||||
implementation("io.ktor:ktor-server-core-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-auth-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-auth-jwt-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-auto-head-response-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-resources")
|
|
||||||
implementation("io.ktor:ktor-server-content-negotiation-jvm")
|
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-websockets-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-cors-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-host-common-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-status-pages-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-netty-jvm")
|
|
||||||
implementation("io.ktor:ktor-server-data-conversion")
|
|
||||||
implementation("io.ktor:ktor-client-content-negotiation")
|
|
||||||
implementation("io.ktor:ktor-client-auth")
|
|
||||||
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
|
|
||||||
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
|
|
||||||
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
|
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
|
|
||||||
implementation("org.postgresql:postgresql:42.7.13")
|
|
||||||
implementation("com.zaxxer:HikariCP:6.3.0")
|
|
||||||
implementation("com.rabbitmq:amqp-client:5.25.0")
|
|
||||||
implementation("com.password4j:password4j:1.8.4")
|
|
||||||
|
|
||||||
// Force version of sub library (for security)
|
|
||||||
implementation("commons-codec:commons-codec:1.13")
|
|
||||||
|
|
||||||
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
|
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
|
|
||||||
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
|
|
||||||
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
|
|
||||||
testImplementation("io.mockk:mockk:1.14.11")
|
|
||||||
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package eventDemo.configuration
|
|
||||||
|
|
||||||
import io.ktor.server.config.ApplicationConfig
|
|
||||||
|
|
||||||
data class Configuration(
|
|
||||||
val jwtSecret: String,
|
|
||||||
val postgresql: Postgresql,
|
|
||||||
val rabbitmq: RabbitMQ,
|
|
||||||
) {
|
|
||||||
data class Postgresql(
|
|
||||||
val url: String,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RabbitMQ(
|
|
||||||
val url: String,
|
|
||||||
val port: Int,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ApplicationConfig.configuration
|
|
||||||
get() =
|
|
||||||
Configuration(
|
|
||||||
jwtSecret = getProperty("jwt.secret"),
|
|
||||||
postgresql =
|
|
||||||
Configuration.Postgresql(
|
|
||||||
url = getProperty("postgresql.url"),
|
|
||||||
username = getProperty("postgresql.username"),
|
|
||||||
password = getProperty("postgresql.password"),
|
|
||||||
),
|
|
||||||
rabbitmq =
|
|
||||||
Configuration.RabbitMQ(
|
|
||||||
url = getProperty("rabbitmq.url"),
|
|
||||||
port = getProperty("rabbitmq.port").toInt(),
|
|
||||||
username = getProperty("rabbitmq.username"),
|
|
||||||
password = getProperty("rabbitmq.password"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun ApplicationConfig.getProperty(path: String): String =
|
|
||||||
propertyOrNull(path)?.getString() ?: error("You must set the $path")
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.configuration
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.infrastructure.configure.configureAuthDi
|
|
||||||
import eventDemo.contexts.game.infrastructure.configuration.injections.application.configureGameDIApplication
|
|
||||||
import eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure.configureGameDIInfrastructure
|
|
||||||
import org.koin.dsl.module
|
|
||||||
|
|
||||||
fun appKoinModule(config: Configuration) =
|
|
||||||
module {
|
|
||||||
configureDIDataSource(config)
|
|
||||||
configureAuthDi()
|
|
||||||
configureGameDIInfrastructure()
|
|
||||||
configureGameDIApplication()
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package eventDemo.configuration
|
|
||||||
|
|
||||||
import com.rabbitmq.client.ConnectionFactory
|
|
||||||
import com.zaxxer.hikari.HikariConfig
|
|
||||||
import com.zaxxer.hikari.HikariDataSource
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.scope.Scope
|
|
||||||
import org.koin.core.scope.ScopeCallback
|
|
||||||
import org.koin.dsl.bind
|
|
||||||
import javax.sql.DataSource
|
|
||||||
|
|
||||||
fun Module.configureDIDataSource(config: Configuration) {
|
|
||||||
// PostgreSQL (for EventStore)
|
|
||||||
single {
|
|
||||||
hikariDataSource(config)
|
|
||||||
.apply {
|
|
||||||
registerCallback(
|
|
||||||
object : ScopeCallback {
|
|
||||||
override fun onScopeClose(scope: Scope) {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} bind DataSource::class
|
|
||||||
|
|
||||||
// RabbitMQ (for EventBus)
|
|
||||||
factory {
|
|
||||||
ConnectionFactory().apply {
|
|
||||||
host = config.rabbitmq.url
|
|
||||||
port = config.rabbitmq.port
|
|
||||||
username = config.rabbitmq.username
|
|
||||||
password = config.rabbitmq.password
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hikariDataSource(config: Configuration): HikariDataSource =
|
|
||||||
HikariConfig()
|
|
||||||
.apply {
|
|
||||||
jdbcUrl = config.postgresql.url
|
|
||||||
username = config.postgresql.username
|
|
||||||
password = config.postgresql.password
|
|
||||||
maximumPoolSize = 10
|
|
||||||
minimumIdle = 10
|
|
||||||
}.let {
|
|
||||||
HikariDataSource(it)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package eventDemo.configuration
|
|
||||||
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.application.install
|
|
||||||
import org.koin.ktor.plugin.Koin
|
|
||||||
import org.koin.logger.slf4jLogger
|
|
||||||
|
|
||||||
fun Application.configureKoin() {
|
|
||||||
install(Koin) {
|
|
||||||
slf4jLogger()
|
|
||||||
|
|
||||||
modules(
|
|
||||||
appKoinModule(environment.config.configuration),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package eventDemo.configuration
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.infrastructure.configure.configureAuth
|
|
||||||
import eventDemo.contexts.game.infrastructure.configuration.ktor.configureUno
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
|
|
||||||
fun Application.configure() {
|
|
||||||
configureKoin()
|
|
||||||
configureAuth()
|
|
||||||
configureUno()
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.application.eventStores
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserEventStore
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
|
|
||||||
class UserEventStoreRepository(
|
|
||||||
val eventStore: UserEventStore,
|
|
||||||
) : UserRepository {
|
|
||||||
override fun get(id: UserId): User? {
|
|
||||||
val events =
|
|
||||||
eventStore
|
|
||||||
.getStream(id)
|
|
||||||
.readAll()
|
|
||||||
if (events.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return events.let { User.loadFromHistory(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun save(user: User) {
|
|
||||||
eventStore.append(user.recordedEvents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.application.eventStores
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
|
|
||||||
interface UserRepository {
|
|
||||||
fun get(id: UserId): User?
|
|
||||||
|
|
||||||
fun save(user: User)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.application.ports
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.events.UserEvent
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStore
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
|
|
||||||
interface UserEventStore : EventStore<UserEvent, UserId>
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.application.ports
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
|
|
||||||
|
|
||||||
interface UserProjectionRepository {
|
|
||||||
fun getByUsername(username: String): UserProjection?
|
|
||||||
|
|
||||||
fun save(user: UserProjection)
|
|
||||||
|
|
||||||
fun getUserIfPasswordIsValid(
|
|
||||||
username: String,
|
|
||||||
rawPassword: String,
|
|
||||||
): UserProjection?
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.domain
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent
|
|
||||||
import eventDemo.contexts.auth.domain.events.UserEvent
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class User(
|
|
||||||
val id: UserId,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
val version: Int,
|
|
||||||
val recordedEvents: Set<UserEvent>,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun createNewUser(
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
): User =
|
|
||||||
apply(NewUserCreatedEvent(username, password, version = 1))
|
|
||||||
|
|
||||||
fun apply(event: NewUserCreatedEvent): User =
|
|
||||||
User(
|
|
||||||
id = event.aggregateId,
|
|
||||||
username = event.username,
|
|
||||||
password = event.password,
|
|
||||||
version = event.version,
|
|
||||||
recordedEvents = setOf(event),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun loadFromHistory(events: Set<UserEvent>): User? =
|
|
||||||
events.fold(null as User?) { acc, event ->
|
|
||||||
when (event) {
|
|
||||||
is NewUserCreatedEvent -> apply(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class NewUserCreatedEvent(
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
override val version: Int,
|
|
||||||
override val createdAt: Instant = Clock.System.now(),
|
|
||||||
override val aggregateId: UserId = UserId(),
|
|
||||||
) : UserEvent {
|
|
||||||
override val eventId: EventId = EventId()
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.domain.events
|
|
||||||
|
|
||||||
import eventDemo.libs.eventSource.Event
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface UserEvent : Event<UserId>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure
|
|
||||||
|
|
||||||
import com.password4j.Hash
|
|
||||||
import com.password4j.Password
|
|
||||||
|
|
||||||
internal fun hashPassword(password: String): Hash =
|
|
||||||
Password.hash(password).addRandomSalt().withArgon2()
|
|
||||||
|
|
||||||
internal fun checkPassword(
|
|
||||||
password: String,
|
|
||||||
hash: Hash,
|
|
||||||
): Boolean =
|
|
||||||
Password.check(password, hash)
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.configure
|
|
||||||
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
|
|
||||||
fun Application.configureAuth() {
|
|
||||||
configureKtorAuth()
|
|
||||||
configureAuthRoutes()
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.configure
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserEventStore
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
|
||||||
import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInPostgresql
|
|
||||||
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjectionRepositoryInPostgresql
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
import org.koin.dsl.bind
|
|
||||||
|
|
||||||
fun Module.configureAuthDi() {
|
|
||||||
singleOf(::UserEventStoreRepository) bind UserRepository::class
|
|
||||||
singleOf(::UserEventStoreInPostgresql) bind UserEventStore::class
|
|
||||||
singleOf(::UserProjectionRepositoryInPostgresql) bind UserProjectionRepository::class
|
|
||||||
}
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.configure
|
|
||||||
|
|
||||||
import eventDemo.configuration.configuration
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
|
||||||
import eventDemo.contexts.auth.infrastructure.rest.createUserRoute
|
|
||||||
import eventDemo.contexts.auth.infrastructure.rest.loginRoute
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import org.koin.ktor.ext.get
|
|
||||||
|
|
||||||
fun Application.configureAuthRoutes() {
|
|
||||||
val userRepository = get<UserEventStoreRepository>()
|
|
||||||
val userProjectionRepository = get<UserProjectionRepository>()
|
|
||||||
routing {
|
|
||||||
createUserRoute(userRepository)
|
|
||||||
loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.persistence.eventStore
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserEventStore
|
|
||||||
import eventDemo.contexts.auth.domain.events.UserEvent
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStore
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream to publish and read the user events.
|
|
||||||
*/
|
|
||||||
class UserEventStoreInMemory :
|
|
||||||
UserEventStore,
|
|
||||||
EventStore<UserEvent, UserId> by EventStoreInMemory()
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.persistence.eventStore
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserEventStore
|
|
||||||
import eventDemo.contexts.auth.domain.events.UserEvent
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStore
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import javax.sql.DataSource
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream to publish and read the user events.
|
|
||||||
*/
|
|
||||||
class UserEventStoreInPostgresql(
|
|
||||||
dataSource: DataSource,
|
|
||||||
) : UserEventStore,
|
|
||||||
EventStore<UserEvent, UserId> by EventStoreInPostgresql(
|
|
||||||
dataSource,
|
|
||||||
{ Json.encodeToString(it) },
|
|
||||||
{ Json.decodeFromString(it) },
|
|
||||||
"auth.user_event_stream",
|
|
||||||
)
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.persistence.projection
|
|
||||||
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
|
|
||||||
data class UserProjection(
|
|
||||||
val id: UserId,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.persistence.projection
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
|
||||||
import eventDemo.contexts.auth.infrastructure.checkPassword
|
|
||||||
import eventDemo.contexts.auth.infrastructure.hashPassword
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import javax.sql.DataSource
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
import kotlin.uuid.toJavaUuid
|
|
||||||
|
|
||||||
class UserProjectionRepositoryInPostgresql(
|
|
||||||
val dataSource: DataSource,
|
|
||||||
) : UserProjectionRepository {
|
|
||||||
override fun getByUsername(username: String): UserProjection? =
|
|
||||||
dataSource.connection
|
|
||||||
.prepareStatement(
|
|
||||||
"""
|
|
||||||
select id, username
|
|
||||||
from auth."user"
|
|
||||||
where id = ?;
|
|
||||||
""".trimIndent(),
|
|
||||||
).use {
|
|
||||||
it.setObject(1, username)
|
|
||||||
it.executeQuery()
|
|
||||||
}.use { resultSet ->
|
|
||||||
if (resultSet.next()) {
|
|
||||||
UserProjection(
|
|
||||||
id = UserId(Uuid.parse(resultSet.getString("id"))),
|
|
||||||
username = resultSet.getString("username"),
|
|
||||||
password = resultSet.getString("password"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun save(user: UserProjection) {
|
|
||||||
dataSource.connection.use { connection ->
|
|
||||||
connection
|
|
||||||
.prepareStatement(
|
|
||||||
"""
|
|
||||||
insert into auth.user (id, username)
|
|
||||||
values (?, ?)
|
|
||||||
""".trimIndent(),
|
|
||||||
).use {
|
|
||||||
it.setObject(1, user.id.id.toJavaUuid())
|
|
||||||
it.setString(2, user.username)
|
|
||||||
it.executeUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getUserIfPasswordIsValid(
|
|
||||||
username: String,
|
|
||||||
rawPassword: String,
|
|
||||||
): UserProjection? {
|
|
||||||
val user = getByUsername(username) ?: return null
|
|
||||||
val isValid = checkPassword(rawPassword, hashPassword(user.password))
|
|
||||||
if (!isValid) return null
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.rest
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
|
||||||
import eventDemo.contexts.auth.infrastructure.configure.makeJwt
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.server.response.respond
|
|
||||||
import io.ktor.server.routing.Route
|
|
||||||
import io.ktor.server.routing.post
|
|
||||||
|
|
||||||
fun Route.loginRoute(
|
|
||||||
jwtSecret: String,
|
|
||||||
userProjectionRepository: UserProjectionRepository,
|
|
||||||
) {
|
|
||||||
post("login/{username}") {
|
|
||||||
val username = call.parameters["username"]!!
|
|
||||||
val rawPassword = call.parameters["password"]!!
|
|
||||||
|
|
||||||
val userProjection =
|
|
||||||
userProjectionRepository.getUserIfPasswordIsValid(username, rawPassword)
|
|
||||||
?: return@post call.respond(HttpStatusCode.BadRequest)
|
|
||||||
|
|
||||||
call.respond(hashMapOf("token" to userProjection.makeJwt(jwtSecret)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.infrastructure.rest
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.contexts.auth.infrastructure.hashPassword
|
|
||||||
import io.ktor.resources.Resource
|
|
||||||
import io.ktor.server.auth.authenticate
|
|
||||||
import io.ktor.server.response.respond
|
|
||||||
import io.ktor.server.routing.Route
|
|
||||||
import io.ktor.server.routing.post
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@Resource("/users")
|
|
||||||
class Users {
|
|
||||||
@Serializable
|
|
||||||
@Resource("/create")
|
|
||||||
class Create(
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API routes to show all games.
|
|
||||||
*/
|
|
||||||
fun Route.createUserRoute(userRepository: UserRepository) {
|
|
||||||
authenticate {
|
|
||||||
// Create a new User, and return there ID
|
|
||||||
post<Users.Create> {
|
|
||||||
val passwordHash = hashPassword(it.password)
|
|
||||||
val user = User.createNewUser(it.username, passwordHash.result)
|
|
||||||
userRepository.save(user)
|
|
||||||
call.respond(
|
|
||||||
object {
|
|
||||||
val id = user.id.toString()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.channels
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.notification.CommandSubscriber
|
|
||||||
import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
|
|
||||||
import eventDemo.shared.game.command.GameCommand
|
|
||||||
import eventDemo.shared.game.notification.Notification
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
|
|
||||||
class GameChannelsSubscriber(
|
|
||||||
private val eventToNotificationSubscriber: EventToNotificationSubscriber,
|
|
||||||
private val commandSubscriber: CommandSubscriber,
|
|
||||||
) {
|
|
||||||
@DelicateCoroutinesApi
|
|
||||||
fun subscribePlayerToGameChannels(
|
|
||||||
gameId: GameId,
|
|
||||||
userId: UserId,
|
|
||||||
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
|
||||||
sendNotificationChannel: SendChannel<Notification>,
|
|
||||||
) {
|
|
||||||
val sub =
|
|
||||||
eventToNotificationSubscriber.subscribeToEventsAndSendNotification(
|
|
||||||
gameId = gameId,
|
|
||||||
currentUserId = userId,
|
|
||||||
outgoingFrameChannel = sendNotificationChannel,
|
|
||||||
)
|
|
||||||
|
|
||||||
commandSubscriber
|
|
||||||
.subscribe(
|
|
||||||
currentUserId = userId,
|
|
||||||
incomingFrameChannel = incomingCommandChannel,
|
|
||||||
).invokeOnCompletion { sub.close() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.command.handlers
|
|
||||||
|
|
||||||
import eventDemo.shared.game.command.GameCommand
|
|
||||||
import eventDemo.shared.game.command.JoinTheGameCommand
|
|
||||||
import eventDemo.shared.game.command.PlayCardCommand
|
|
||||||
import eventDemo.shared.game.command.ReadyToPlayCommand
|
|
||||||
import eventDemo.shared.game.command.TakeCartFromDrawPileCommand
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import java.util.Collections
|
|
||||||
|
|
||||||
class GameCommandHandlerDispatcher(
|
|
||||||
private val playCardHandler: PlayCardHandler,
|
|
||||||
private val readyToPlayHandler: ReadyToPlayHandler,
|
|
||||||
private val joinTheGameHandler: JoinTheGameHandler,
|
|
||||||
private val takeCartFromDrawPileHandler: TakeCartFromDrawPileHandler,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
val lock: MutableMap<GameId, String> = Collections.synchronizedMap(mutableMapOf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dispatch(command: GameCommand) {
|
|
||||||
synchronized(lock.getOrPut(command.payload.aggregateId) { command.payload.aggregateId.toString() }) {
|
|
||||||
when (command) {
|
|
||||||
is JoinTheGameCommand -> joinTheGameHandler.handle(command)
|
|
||||||
is ReadyToPlayCommand -> readyToPlayHandler.handle(command)
|
|
||||||
is PlayCardCommand -> playCardHandler.handle(command)
|
|
||||||
is TakeCartFromDrawPileCommand -> takeCartFromDrawPileHandler.handle(command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.command.handlers
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.libs.eventSource.eventStore.VersionConflictException
|
|
||||||
import eventDemo.shared.command.Command
|
|
||||||
import eventDemo.shared.game.command.GameCommand
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
sealed interface CommandHandler<C : Command> {
|
|
||||||
fun handle(command: C)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class GameEventManager(
|
|
||||||
private val gameRepository: GameRepository,
|
|
||||||
private val gameEventBus: GameEventBus,
|
|
||||||
) {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
fun GameCommand.getGame(): Game =
|
|
||||||
gameRepository.get(payload.aggregateId) ?: error("Game not found")
|
|
||||||
|
|
||||||
fun GameEvent.getGame(): Game =
|
|
||||||
gameRepository.get(aggregateId) ?: error("Game not found")
|
|
||||||
|
|
||||||
@Throws(VersionConflictException::class)
|
|
||||||
protected fun Game.saveEvents(): Game {
|
|
||||||
gameRepository.save(this)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun Game.publishEvents(): Game {
|
|
||||||
gameEventBus.publish(recordedEvents)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
protected inline fun <reified G : Game> Game.isStatusOrFail(message: String): G =
|
|
||||||
this as? G ?: throw CommandException(message)
|
|
||||||
|
|
||||||
protected fun <T> retry(
|
|
||||||
mapAttempts: Int = 5,
|
|
||||||
block: () -> T,
|
|
||||||
): T =
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
} catch (e: VersionConflictException) {
|
|
||||||
if (mapAttempts > 0) {
|
|
||||||
logger.warn { "retry after version conflict (attempts left: $mapAttempts)" }
|
|
||||||
retry(mapAttempts - 1, block)
|
|
||||||
} else {
|
|
||||||
logger.error { "Version conflict retry failed" }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.command.handlers
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
|
||||||
import eventDemo.shared.game.command.JoinTheGameCommand
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
class JoinTheGameHandler(
|
|
||||||
gameRepository: GameRepository,
|
|
||||||
gameEventBus: GameEventBus,
|
|
||||||
private val userRepository: UserRepository,
|
|
||||||
) : GameEventManager(gameRepository, gameEventBus),
|
|
||||||
CommandHandler<JoinTheGameCommand> {
|
|
||||||
override fun handle(command: JoinTheGameCommand) {
|
|
||||||
val user = userRepository.get(command.userId) ?: error("User with id ${command.userId} doesn't exist")
|
|
||||||
retry {
|
|
||||||
command
|
|
||||||
.getGame()
|
|
||||||
.isStatusOrFail<GameCreated>("The game is started")
|
|
||||||
.userJoinTheGame(
|
|
||||||
userId = command.userId,
|
|
||||||
name = user.username,
|
|
||||||
).saveEvents()
|
|
||||||
.publishEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.command.handlers
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
|
||||||
import eventDemo.shared.game.command.PlayCardCommand
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
class PlayCardHandler(
|
|
||||||
gameRepository: GameRepository,
|
|
||||||
gameEventBus: GameEventBus,
|
|
||||||
) : GameEventManager(gameRepository, gameEventBus),
|
|
||||||
CommandHandler<PlayCardCommand> {
|
|
||||||
override fun handle(command: PlayCardCommand) {
|
|
||||||
command
|
|
||||||
.getGame()
|
|
||||||
.isStatusOrFail<GameStarted>("The game is not started")
|
|
||||||
.playTheCard(
|
|
||||||
card = command.payload.card,
|
|
||||||
playerId = command.payload.playerId,
|
|
||||||
chosenColor = command.payload.chosenColor,
|
|
||||||
).saveEvents()
|
|
||||||
.publishEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.command.handlers
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
|
||||||
import eventDemo.shared.game.command.ReadyToPlayCommand
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to set as ready to play
|
|
||||||
*/
|
|
||||||
class ReadyToPlayHandler(
|
|
||||||
gameRepository: GameRepository,
|
|
||||||
gameEventBus: GameEventBus,
|
|
||||||
) : GameEventManager(gameRepository, gameEventBus),
|
|
||||||
CommandHandler<ReadyToPlayCommand> {
|
|
||||||
override fun handle(command: ReadyToPlayCommand) {
|
|
||||||
command
|
|
||||||
.getGame()
|
|
||||||
.isStatusOrFail<GameCreated>("The game is started")
|
|
||||||
.setReadyPlayer(command.payload.playerId)
|
|
||||||
.saveEvents()
|
|
||||||
.publishEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.command.handlers
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
|
||||||
import eventDemo.shared.game.command.TakeCartFromDrawPileCommand
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to draw card on draw pile.
|
|
||||||
*
|
|
||||||
* Is can be triggered when you cannot play any card in your hand.
|
|
||||||
*/
|
|
||||||
class TakeCartFromDrawPileHandler(
|
|
||||||
gameRepository: GameRepository,
|
|
||||||
gameEventBus: GameEventBus,
|
|
||||||
) : GameEventManager(gameRepository, gameEventBus),
|
|
||||||
CommandHandler<TakeCartFromDrawPileCommand> {
|
|
||||||
override fun handle(command: TakeCartFromDrawPileCommand) {
|
|
||||||
command
|
|
||||||
.getGame()
|
|
||||||
.isStatusOrFail<GameStarted>("The game is not started")
|
|
||||||
.playerTakeCartFromDrawPile(command.payload.playerId, 1)
|
|
||||||
.saveEvents()
|
|
||||||
.publishEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.eventStores
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventStore
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.libs.eventSource.eventStore.VersionConflictException
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
class GameEventStoreRepository(
|
|
||||||
val eventStore: GameEventStore,
|
|
||||||
) : GameRepository {
|
|
||||||
override fun get(id: GameId): Game? {
|
|
||||||
val events =
|
|
||||||
eventStore
|
|
||||||
.getStream(id)
|
|
||||||
.readAll()
|
|
||||||
if (events.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return events.let { Game.loadFromHistory(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(VersionConflictException::class)
|
|
||||||
override fun save(game: Game) {
|
|
||||||
eventStore.append(game.recordedEvents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.eventStores
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameInit
|
|
||||||
import eventDemo.libs.eventSource.eventStore.VersionConflictException
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
interface GameRepository {
|
|
||||||
fun get(id: GameId): Game?
|
|
||||||
|
|
||||||
@Throws(VersionConflictException::class)
|
|
||||||
fun save(game: Game)
|
|
||||||
|
|
||||||
fun getOrCreate(gameId: GameId): Game =
|
|
||||||
get(gameId) ?: create(gameId)
|
|
||||||
|
|
||||||
fun create(gameId: GameId = GameId()): GameCreated =
|
|
||||||
GameInit.createNewGame(gameId).also { save(it) }
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.logging
|
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
inline fun <T> withLoggingContext(
|
|
||||||
vararg pair: Pair<LoggingContextKeys, *>,
|
|
||||||
body: () -> T,
|
|
||||||
): T =
|
|
||||||
withLoggingContext(
|
|
||||||
*pair
|
|
||||||
.map {
|
|
||||||
it.first.name to it.second.toString()
|
|
||||||
}.toTypedArray(),
|
|
||||||
restorePrevious = true,
|
|
||||||
body = body,
|
|
||||||
)
|
|
||||||
|
|
||||||
// inline fun withLoggingContext(
|
|
||||||
// vararg pair: Pair<LoggingContextKeys, *>,
|
|
||||||
// body: () -> Unit,
|
|
||||||
// ) =
|
|
||||||
// withLoggingContext(
|
|
||||||
// *pair
|
|
||||||
// .map {
|
|
||||||
// it.first.name to it.second.toString()
|
|
||||||
// }.toTypedArray(),
|
|
||||||
// restorePrevious = true,
|
|
||||||
// body = body,
|
|
||||||
// )
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.logging
|
|
||||||
|
|
||||||
enum class LoggingContextKeys {
|
|
||||||
CurrentUserId,
|
|
||||||
Notification,
|
|
||||||
Game,
|
|
||||||
Event,
|
|
||||||
Command,
|
|
||||||
}
|
|
||||||
-127
@@ -1,127 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.notification
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameCreatedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameStartedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerActionEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerReadyEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerWinEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
|
||||||
import eventDemo.shared.game.notification.ItsTheTurnOfNotification
|
|
||||||
import eventDemo.shared.game.notification.Notification
|
|
||||||
import eventDemo.shared.game.notification.PilesShuffledNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerAsJoinTheGameNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerHavePassNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerWasReadyNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerWinNotification
|
|
||||||
import eventDemo.shared.game.notification.TheGameWasStartedNotification
|
|
||||||
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
|
|
||||||
import eventDemo.shared.game.notification.YourNewCardNotification
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
fun GameEvent.toNotification(
|
|
||||||
game: Game,
|
|
||||||
currentUserId: UserId,
|
|
||||||
): Iterable<Notification> =
|
|
||||||
Iterable {
|
|
||||||
iterator {
|
|
||||||
context(iterator: SequenceScope<Notification>)
|
|
||||||
suspend fun Notification.send() {
|
|
||||||
withLoggingContext("notification" to (this).toString()) {
|
|
||||||
logger.info { "Notification sent" }
|
|
||||||
iterator.yield(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PlayerActionEvent.isFromCurrentUser(): Boolean =
|
|
||||||
game.players.get(currentUserId).id == playerId
|
|
||||||
|
|
||||||
when (this@toNotification) {
|
|
||||||
is GameCreatedEvent -> {
|
|
||||||
// Nothing to send
|
|
||||||
}
|
|
||||||
|
|
||||||
is DrawFilledWithDiscardEvent -> {
|
|
||||||
PilesShuffledNotification().send()
|
|
||||||
}
|
|
||||||
|
|
||||||
is NewPlayerEvent -> {
|
|
||||||
if (this@toNotification.player.userId != currentUserId) {
|
|
||||||
PlayerAsJoinTheGameNotification(
|
|
||||||
player = this@toNotification.player,
|
|
||||||
).send()
|
|
||||||
} else {
|
|
||||||
WelcomeToTheGameNotification(
|
|
||||||
players = game.players,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is CardIsPlayedEvent -> {
|
|
||||||
PlayerAsPlayACardNotification(
|
|
||||||
playerId = this@toNotification.playerId,
|
|
||||||
card = this@toNotification.card,
|
|
||||||
).send()
|
|
||||||
|
|
||||||
if (game is GameStarted) {
|
|
||||||
ItsTheTurnOfNotification(
|
|
||||||
player = game.nextPlayer,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is GameStartedEvent -> {
|
|
||||||
TheGameWasStartedNotification(
|
|
||||||
hand =
|
|
||||||
game.players
|
|
||||||
.get(currentUserId)
|
|
||||||
.hand.cards,
|
|
||||||
).send()
|
|
||||||
|
|
||||||
if (game is GameStarted) {
|
|
||||||
ItsTheTurnOfNotification(player = game.nextPlayer)
|
|
||||||
.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerHaveDrawCardEvent -> {
|
|
||||||
if (this@toNotification.isFromCurrentUser()) {
|
|
||||||
YourNewCardNotification(
|
|
||||||
cards = this@toNotification.takenCards,
|
|
||||||
).send()
|
|
||||||
} else {
|
|
||||||
PlayerHavePassNotification(
|
|
||||||
playerId = this@toNotification.playerId,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game is GameStarted) {
|
|
||||||
ItsTheTurnOfNotification(player = game.nextPlayer)
|
|
||||||
.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerReadyEvent -> {
|
|
||||||
PlayerWasReadyNotification(
|
|
||||||
playerId = this@toNotification.playerId,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerWinEvent -> {
|
|
||||||
PlayerWinNotification(
|
|
||||||
playerId = this@toNotification.playerId,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.notification
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Command
|
|
||||||
import eventDemo.contexts.game.application.logging.LoggingContextKeys.CurrentUserId
|
|
||||||
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Event
|
|
||||||
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Game
|
|
||||||
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Notification
|
|
||||||
import eventDemo.contexts.game.application.logging.withLoggingContext
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.libs.bus.Bus
|
|
||||||
import eventDemo.libs.command.CommandUnicityChecker
|
|
||||||
import eventDemo.shared.game.command.GameCommand
|
|
||||||
import eventDemo.shared.game.notification.Notification
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class EventToNotificationSubscriber(
|
|
||||||
private val gameEventBus: GameEventBus,
|
|
||||||
private val gameRepository: GameRepository,
|
|
||||||
) {
|
|
||||||
fun subscribeToEventsAndSendNotification(
|
|
||||||
gameId: GameId,
|
|
||||||
currentUserId: UserId,
|
|
||||||
outgoingFrameChannel: SendChannel<Notification>,
|
|
||||||
): Bus.Subscription =
|
|
||||||
withLoggingContext(CurrentUserId to currentUserId) {
|
|
||||||
gameEventBus.subscribe { event ->
|
|
||||||
val game = gameRepository.get(gameId) ?: error("Game not found")
|
|
||||||
withLoggingContext(Event to event, Game to game) {
|
|
||||||
event
|
|
||||||
.toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
).forEach { notification ->
|
|
||||||
withLoggingContext(Notification to notification) {
|
|
||||||
outgoingFrameChannel.trySendBlocking(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CommandSubscriber(
|
|
||||||
private val gameCommandHandlerDispatcher: GameCommandHandlerDispatcher,
|
|
||||||
) {
|
|
||||||
private val controller = CommandUnicityChecker<GameCommand>()
|
|
||||||
|
|
||||||
@DelicateCoroutinesApi
|
|
||||||
fun subscribe(
|
|
||||||
currentUserId: UserId,
|
|
||||||
incomingFrameChannel: ReceiveChannel<GameCommand>,
|
|
||||||
): Job =
|
|
||||||
GlobalScope.launch {
|
|
||||||
for (command in incomingFrameChannel) {
|
|
||||||
withLoggingContext(CurrentUserId to currentUserId, Command to command) {
|
|
||||||
controller.runOnlyOnce(command) {
|
|
||||||
gameCommandHandlerDispatcher.dispatch(command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.ports
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.libs.bus.Bus
|
|
||||||
|
|
||||||
interface GameEventBus : Bus<GameEvent>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.ports
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStore
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
interface GameEventStore : EventStore<GameEvent, GameId>
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.domain.event.projection
|
|
||||||
|
|
||||||
import eventDemo.shared.game.projection.GameList
|
|
||||||
|
|
||||||
interface GameListRepository {
|
|
||||||
fun getList(
|
|
||||||
limit: Int = 100,
|
|
||||||
offset: Int = 0,
|
|
||||||
): List<GameList>
|
|
||||||
|
|
||||||
fun save(gameList: GameList)
|
|
||||||
|
|
||||||
fun subscribeToBus()
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.ports
|
|
||||||
|
|
||||||
import eventDemo.libs.bus.Bus
|
|
||||||
import eventDemo.shared.game.projection.GameProjection
|
|
||||||
|
|
||||||
interface GameProjectionBus : Bus<GameProjection>
|
|
||||||
-55
@@ -1,55 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.projections
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameCreatedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameStartedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerReadyEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerWinEvent
|
|
||||||
import eventDemo.shared.game.projection.GameList
|
|
||||||
|
|
||||||
fun GameList.applyEvent(event: GameEvent): GameList =
|
|
||||||
when (event) {
|
|
||||||
is GameCreatedEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is NewPlayerEvent -> {
|
|
||||||
copy(
|
|
||||||
players = players + event.player,
|
|
||||||
status = GameList.Status.OPENING,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is GameStartedEvent -> {
|
|
||||||
copy(
|
|
||||||
status = GameList.Status.IS_STARTED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerWinEvent -> {
|
|
||||||
copy(
|
|
||||||
winners = winners,
|
|
||||||
status = GameList.Status.FINISH,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is CardIsPlayedEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerHaveDrawCardEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerReadyEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is DrawFilledWithDiscardEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.reaction
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.GameEventManager
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.logging.LoggingContextKeys
|
|
||||||
import eventDemo.contexts.game.application.logging.withLoggingContext
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import java.util.concurrent.ConcurrentSkipListSet
|
|
||||||
|
|
||||||
class ReactionListener(
|
|
||||||
gameRepository: GameRepository,
|
|
||||||
private val gameEventBus: GameEventBus,
|
|
||||||
) : GameEventManager(gameRepository, gameEventBus) {
|
|
||||||
private companion object Config {
|
|
||||||
val registeredListeners = ConcurrentSkipListSet<GameEventBus>()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger { }
|
|
||||||
|
|
||||||
fun subscribeToBus() {
|
|
||||||
if (registeredListeners.add(gameEventBus)) {
|
|
||||||
gameEventBus.subscribe { event ->
|
|
||||||
val game = event.getGame()
|
|
||||||
withLoggingContext(LoggingContextKeys.Game to game) {
|
|
||||||
sendStartGameEvent(game)
|
|
||||||
sendWinnerEvent(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"${this::class.simpleName} is already init for this bus".let {
|
|
||||||
logger.error { it }
|
|
||||||
error(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendStartGameEvent(game: Game) {
|
|
||||||
if (game is GameCreated && game.allPlayerIsReady) {
|
|
||||||
game
|
|
||||||
.startGame()
|
|
||||||
.saveEvents()
|
|
||||||
.publishEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendWinnerEvent(game: Game) {
|
|
||||||
if (game is GameStarted && game.lastPlayerId != null) {
|
|
||||||
val lastPlayerWin =
|
|
||||||
game
|
|
||||||
.players
|
|
||||||
.get(game.lastPlayerId)
|
|
||||||
.hand.size == 0
|
|
||||||
if (lastPlayerWin) {
|
|
||||||
game
|
|
||||||
.playerWin(game.lastPlayerId)
|
|
||||||
.saveEvents()
|
|
||||||
.publishEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.Card.Color
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An [GameEvent] to represent a played card.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class CardIsPlayedEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val card: Card,
|
|
||||||
override val playerId: Player.PlayerId,
|
|
||||||
val chosenColor: Color? = null,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
|
|
||||||
val theColorCard get() = if (card is Card.CardWithColor) card.color else chosenColor
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.DiscardPile
|
|
||||||
import eventDemo.shared.game.DrawPile
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the Pile are shuffled after the draw pille was empty
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
class DrawFilledWithDiscardEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val newDrawPile: DrawPile,
|
|
||||||
val newDiscardPile: DiscardPile,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when all players are ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class GameCreatedEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.libs.eventSource.Event
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import eventDemo.shared.serializers.GameIdSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An [Event] of a Game.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed interface GameEvent : Event<GameId> {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId
|
|
||||||
|
|
||||||
@Serializable(with = GameIdSerializer::class)
|
|
||||||
override val aggregateId: GameId
|
|
||||||
override val version: Int
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.DiscardPile
|
|
||||||
import eventDemo.shared.game.DrawPile
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerHand
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import eventDemo.shared.serializers.PlayerIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when all players are ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class GameStartedEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
@Serializable(with = PlayerIdSerializer::class)
|
|
||||||
val firstPlayer: Player.PlayerId,
|
|
||||||
val playersHans: Map<Player.PlayerId, PlayerHand>,
|
|
||||||
val drawPile: DrawPile,
|
|
||||||
val discardPile: DiscardPile,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.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,
|
|
||||||
PlayerActionEvent {
|
|
||||||
override val playerId: Player.PlayerId get() = player.id
|
|
||||||
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface PlayerActionEvent : GameEvent {
|
|
||||||
val playerId: Player.PlayerId
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import eventDemo.shared.serializers.PlayerIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player can play.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerHaveDrawCardEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
@Serializable(with = PlayerIdSerializer::class)
|
|
||||||
override val playerId: Player.PlayerId,
|
|
||||||
val takenCards: Set<Card>,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import eventDemo.shared.serializers.PlayerIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player is ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerReadyEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
@Serializable(with = PlayerIdSerializer::class)
|
|
||||||
override val playerId: Player.PlayerId,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.events
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.EventIdSerializer
|
|
||||||
import eventDemo.shared.serializers.PlayerIdSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player is ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerWinEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
@Serializable(with = PlayerIdSerializer::class)
|
|
||||||
override val playerId: Player.PlayerId,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = EventIdSerializer::class)
|
|
||||||
override val eventId: EventId = EventId(Uuid.random())
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.errors
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Deck
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
|
|
||||||
abstract class GameException(
|
|
||||||
message: String,
|
|
||||||
) : Exception(message)
|
|
||||||
|
|
||||||
abstract class IllegalActionException(
|
|
||||||
message: String,
|
|
||||||
) : GameException(message)
|
|
||||||
|
|
||||||
class ItsNotTheTurnException(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
) : IllegalActionException("It is not the turn of the player") {
|
|
||||||
constructor(playerId: Player.PlayerId, event: GameEvent) : this(playerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
class TheCardIsAColorCardException(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
) : IllegalActionException("The card is a color card")
|
|
||||||
|
|
||||||
class TheCardHasNoColorException(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
) : IllegalActionException("The card has no color, you must chose a color")
|
|
||||||
|
|
||||||
class ThePlayerHasRemainingCardsException(
|
|
||||||
val player: Player,
|
|
||||||
) : IllegalActionException("The player has remaining cards")
|
|
||||||
|
|
||||||
class ThePlayerHasAlreadyWinException(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
) : IllegalActionException("The player has already win")
|
|
||||||
|
|
||||||
class ThePlayerIsNotInTheGameException(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
) : IllegalActionException("The player is not in the game")
|
|
||||||
|
|
||||||
class ThePlayerMustPlayACardException(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
val playableCards: Set<Card>,
|
|
||||||
) : IllegalActionException("The player must be play a card")
|
|
||||||
|
|
||||||
class NeedMorePlayersToStartGameException(
|
|
||||||
val players: PlayerList,
|
|
||||||
) : IllegalActionException("You cannot start a game with less than 2 players!")
|
|
||||||
|
|
||||||
class AllPlayerNotReadyException(
|
|
||||||
val players: PlayerList,
|
|
||||||
) : IllegalActionException("All players not ready!")
|
|
||||||
|
|
||||||
class DeckMissingCardsException(
|
|
||||||
val players: PlayerList,
|
|
||||||
deck: Deck,
|
|
||||||
) : IllegalActionException("The deck missing cards")
|
|
||||||
|
|
||||||
class InconsistentGameException(
|
|
||||||
val game: Game,
|
|
||||||
) : GameException("Inconsistent game state")
|
|
||||||
|
|
||||||
class InconsistentEventVersionException(
|
|
||||||
val game: Set<GameEvent>,
|
|
||||||
) : GameException("Inconsistent event version")
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameCreatedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameStartedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerReadyEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerWinEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.GameException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.InconsistentEventVersionException
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
sealed interface Game {
|
|
||||||
val aggregateId: GameId
|
|
||||||
val players: PlayerList
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On each modification, an event is put in their
|
|
||||||
*/
|
|
||||||
val recordedEvents: Set<GameEvent>
|
|
||||||
val version: Int
|
|
||||||
|
|
||||||
enum class Direction {
|
|
||||||
CLOCKWISE,
|
|
||||||
COUNTER_CLOCKWISE,
|
|
||||||
;
|
|
||||||
|
|
||||||
fun revert(): Direction =
|
|
||||||
if (this === CLOCKWISE) {
|
|
||||||
COUNTER_CLOCKWISE
|
|
||||||
} else {
|
|
||||||
CLOCKWISE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun loadFromHistory(events: Set<GameEvent>): Game =
|
|
||||||
events
|
|
||||||
.fold(GameInit(events.first().aggregateId)) { game: Game, event ->
|
|
||||||
game.run {
|
|
||||||
when (event) {
|
|
||||||
is GameCreatedEvent if this is GameInit -> applyEvent(event)
|
|
||||||
is GameCreatedEvent -> error("Game is already created")
|
|
||||||
is NewPlayerEvent if this is GameCreated -> applyEvent(event)
|
|
||||||
is NewPlayerEvent -> error("Game is already stared")
|
|
||||||
is PlayerReadyEvent if this is GameCreated -> applyEvent(event)
|
|
||||||
is PlayerReadyEvent -> error("Game is already stared")
|
|
||||||
is GameStartedEvent if this is GameCreated -> applyEvent(event)
|
|
||||||
is GameStartedEvent -> error("Game is already started")
|
|
||||||
is CardIsPlayedEvent if this is GameStarted -> applyEvent(event)
|
|
||||||
is CardIsPlayedEvent -> error("Game is end")
|
|
||||||
is PlayerHaveDrawCardEvent if this is GameStarted -> applyEvent(event)
|
|
||||||
is PlayerHaveDrawCardEvent -> error("Game is end")
|
|
||||||
is PlayerWinEvent if this is GameStarted -> applyEvent(event)
|
|
||||||
is PlayerWinEvent -> error("Game is end")
|
|
||||||
is DrawFilledWithDiscardEvent if this is GameStarted -> applyEvent(event)
|
|
||||||
is DrawFilledWithDiscardEvent -> error("Game is end")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.let {
|
|
||||||
when (it) {
|
|
||||||
is GameInit -> it
|
|
||||||
is GameCreated -> it.copy(recordedEvents = emptySet())
|
|
||||||
is GameEnded -> it.copy(recordedEvents = emptySet())
|
|
||||||
is GameStarted -> it.copy(recordedEvents = emptySet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun <T : GameEvent> T.checkState(
|
|
||||||
block: (T) -> Boolean,
|
|
||||||
exception: (T) -> GameException,
|
|
||||||
): T {
|
|
||||||
if (!block(this)) throw exception(this)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameStartedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerReadyEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.AllPlayerNotReadyException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.DeckMissingCardsException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.NeedMorePlayersToStartGameException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.DiscardPile
|
|
||||||
import eventDemo.shared.game.DrawPile
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerHand
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
|
|
||||||
data class GameCreated(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val players: PlayerList = PlayerList(),
|
|
||||||
val playersStatus: Map<Player.PlayerId, PlayerStatus> = emptyMap(),
|
|
||||||
override val recordedEvents: Set<GameEvent>,
|
|
||||||
override val version: Int,
|
|
||||||
) : Game {
|
|
||||||
val allPlayerIsReady: Boolean
|
|
||||||
get() {
|
|
||||||
return playersStatus.isNotEmpty() && playersStatus.values.all { it == PlayerStatus.Ready }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startGame(deck: Deck = newDeck().shuffleDeck()): GameStarted {
|
|
||||||
val (drawPile, discardPile, playersHands) =
|
|
||||||
initPiles(deck)
|
|
||||||
.let { (drawPile, discardPile) ->
|
|
||||||
createHandsFromDrawPile(drawPile)
|
|
||||||
.let { (drawPile, playersHands) ->
|
|
||||||
Triple(drawPile, discardPile, playersHands)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GameStartedEvent(
|
|
||||||
aggregateId = aggregateId,
|
|
||||||
firstPlayer = players.randomPlayer().id,
|
|
||||||
version = version + 1,
|
|
||||||
drawPile = drawPile,
|
|
||||||
discardPile = discardPile,
|
|
||||||
playersHans = playersHands,
|
|
||||||
).checkState(
|
|
||||||
{ players.size > 1 },
|
|
||||||
{ NeedMorePlayersToStartGameException(players) },
|
|
||||||
).checkState(
|
|
||||||
{ allPlayerIsReady },
|
|
||||||
{ AllPlayerNotReadyException(players) },
|
|
||||||
).checkState(
|
|
||||||
{ deck.size == 108 },
|
|
||||||
{ DeckMissingCardsException(players, deck) },
|
|
||||||
).also { if (it.drawPile.size + it.discardPile.size + playersHands.values.sumOf { it.size } != 108) error("missing cards!") }
|
|
||||||
.run(::applyEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initPiles(deck: Set<Card>): Pair<DrawPile, DiscardPile> =
|
|
||||||
DrawPile(deck)
|
|
||||||
.generateValidDrawPile()
|
|
||||||
.take(1)
|
|
||||||
.let { (draw, cards) ->
|
|
||||||
draw to DiscardPile(cards)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createHandsFromDrawPile(drawPile: DrawPile) =
|
|
||||||
players
|
|
||||||
.map { it.id }
|
|
||||||
.fold(Pair(drawPile, emptyMap<Player.PlayerId, PlayerHand>())) { (drawAcc, handsAcc), playerId ->
|
|
||||||
drawAcc
|
|
||||||
.take(7)
|
|
||||||
.let { (draw, hand) ->
|
|
||||||
Pair(
|
|
||||||
draw,
|
|
||||||
handsAcc + (playerId to PlayerHand(hand)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun userJoinTheGame(
|
|
||||||
userId: UserId,
|
|
||||||
name: String,
|
|
||||||
): GameCreated {
|
|
||||||
if (players.map { it.userId }.contains(userId)) {
|
|
||||||
throw IllegalStateException("User $userId already in party")
|
|
||||||
}
|
|
||||||
|
|
||||||
val player = Player(name, userId)
|
|
||||||
|
|
||||||
return applyEvent(
|
|
||||||
NewPlayerEvent(
|
|
||||||
aggregateId = aggregateId,
|
|
||||||
player = player,
|
|
||||||
version = version + 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setReadyPlayer(playerId: Player.PlayerId): GameCreated {
|
|
||||||
if (!players.map { it.id }.contains(playerId)) {
|
|
||||||
throw ThePlayerIsNotInTheGameException(playerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlayerReadyEvent(aggregateId, playerId, version + 1)
|
|
||||||
.run(::applyEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun applyEvent(event: NewPlayerEvent): GameCreated =
|
|
||||||
copy(
|
|
||||||
players = players + (event.player),
|
|
||||||
playersStatus = playersStatus + (event.player.id to PlayerStatus.Waiting),
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
version = version + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun applyEvent(event: PlayerReadyEvent): GameCreated =
|
|
||||||
copy(
|
|
||||||
playersStatus = playersStatus + (event.playerId to PlayerStatus.Ready),
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
version = version + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun applyEvent(event: GameStartedEvent): GameStarted =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = event.aggregateId,
|
|
||||||
players =
|
|
||||||
players
|
|
||||||
.map {
|
|
||||||
it.copy(hand = event.playersHans[it.id] ?: error("Player ${it.id} not found"))
|
|
||||||
}.let { PlayerList(it.toSet()) },
|
|
||||||
lastPlayerId = null,
|
|
||||||
nextPlayerId = event.firstPlayer,
|
|
||||||
drawPile = event.drawPile,
|
|
||||||
discardPile = event.discardPile,
|
|
||||||
version = event.version + 1,
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
currentColor = event.discardPile.topCardColor ?: error("The discard pile was not initialized!"),
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class PlayerStatus {
|
|
||||||
Ready,
|
|
||||||
Waiting,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias Deck = Set<Card>
|
|
||||||
|
|
||||||
fun newDeck(): 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() }
|
|
||||||
}.let {
|
|
||||||
it + (1..4).map { Card.ChangeColorCard() }
|
|
||||||
}.toSet()
|
|
||||||
|
|
||||||
fun Set<Card>.shuffleDeck(): Set<Card> {
|
|
||||||
if (isDisabled) return this
|
|
||||||
return shuffled().toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PlayerList.randomPlayer(): Player {
|
|
||||||
if (isDisabled) return first()
|
|
||||||
return random()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isDisabled = false
|
|
||||||
|
|
||||||
fun disableRandomForTest() {
|
|
||||||
isDisabled = true
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
data class GameEnded(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val players: PlayerList,
|
|
||||||
val playerWins: Set<Player.PlayerId> = emptySet(),
|
|
||||||
override val version: Int,
|
|
||||||
override val recordedEvents: Set<GameEvent>,
|
|
||||||
) : Game {
|
|
||||||
init {
|
|
||||||
if (!players.map { it.id }.containsAll(playerWins)) {
|
|
||||||
throw IllegalArgumentException("Player ${players.map { it.id }} were not in players")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.GameCreatedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
data class GameInit(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
) : Game {
|
|
||||||
override val players: PlayerList = PlayerList()
|
|
||||||
override var recordedEvents: Set<GameEvent> = emptySet()
|
|
||||||
|
|
||||||
// 0 = no events; not persisted; not really exist.
|
|
||||||
override var version: Int = 0
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun createNewGame(gameId: GameId = GameId()): GameCreated {
|
|
||||||
val event = GameCreatedEvent(gameId, 1)
|
|
||||||
return GameInit(event.aggregateId).run {
|
|
||||||
event.run(::applyEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun applyEvent(event: GameCreatedEvent): GameCreated =
|
|
||||||
GameCreated(aggregateId, recordedEvents = setOf(event), version = event.version)
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerActionEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerWinEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.InconsistentGameException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.ItsNotTheTurnException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.TheCardHasNoColorException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.TheCardIsAColorCardException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.ThePlayerHasAlreadyWinException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.ThePlayerHasRemainingCardsException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.ThePlayerIsNotInTheGameException
|
|
||||||
import eventDemo.contexts.game.domain.game.errors.ThePlayerMustPlayACardException
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game.Direction
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.Card.Color
|
|
||||||
import eventDemo.shared.game.DiscardPile
|
|
||||||
import eventDemo.shared.game.DrawPile
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
fun PlayerList.nextPlayerTurn(
|
|
||||||
lastPlayerId: Player.PlayerId,
|
|
||||||
direction: Direction,
|
|
||||||
): Player.PlayerId {
|
|
||||||
val lastPlayer = get(lastPlayerId)
|
|
||||||
val playersLastTurn = filter { it.hand.cards.isNotEmpty() || it == lastPlayer }
|
|
||||||
|
|
||||||
return playersLastTurn
|
|
||||||
.indexOf(lastPlayer)
|
|
||||||
.let { lastPlayerIndex ->
|
|
||||||
if (direction == Direction.CLOCKWISE) {
|
|
||||||
if (lastPlayerIndex == playersLastTurn.size - 1) {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
lastPlayerIndex + 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (lastPlayerIndex == 0) {
|
|
||||||
playersLastTurn.size - 1
|
|
||||||
} else {
|
|
||||||
lastPlayerIndex - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.let { nextPlayerIndex -> elementAt(nextPlayerIndex).id }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GameStarted(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val players: PlayerList,
|
|
||||||
val drawPile: DrawPile,
|
|
||||||
val discardPile: DiscardPile,
|
|
||||||
val lastPlayerId: Player.PlayerId?,
|
|
||||||
val nextPlayerId: Player.PlayerId,
|
|
||||||
val currentColor: Color,
|
|
||||||
val playedTurnHistory: List<History> = emptyList(),
|
|
||||||
val direction: Direction = Direction.CLOCKWISE,
|
|
||||||
val playerWins: Set<Player.PlayerId> = emptySet(),
|
|
||||||
override val version: Int,
|
|
||||||
override val recordedEvents: Set<GameEvent>,
|
|
||||||
) : Game {
|
|
||||||
val playersInGame by lazy { players.filter { it.hand.cards.isNotEmpty() } }
|
|
||||||
|
|
||||||
val lastPlayedCard: Card? by lazy { discardPile.topCard }
|
|
||||||
|
|
||||||
data class History(
|
|
||||||
val playerId: Player.PlayerId,
|
|
||||||
val event: GameEvent,
|
|
||||||
val direction: Direction,
|
|
||||||
)
|
|
||||||
|
|
||||||
val lastPlayer: Player? by lazy { lastPlayerId?.let { players.get(it) } }
|
|
||||||
|
|
||||||
val nextPlayer: Player by lazy { players.get(nextPlayerId) }
|
|
||||||
|
|
||||||
fun canBePlayThisCard(card: Card): Boolean {
|
|
||||||
val cardOnBoard = discardPile.topCard ?: return false
|
|
||||||
return when (cardOnBoard) {
|
|
||||||
is Card.NumericCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.CardWith4Color -> true
|
|
||||||
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
|
|
||||||
is Card.CardWithColor -> card.color == cardOnBoard.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.ReverseCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.ReverseCard -> true
|
|
||||||
is Card.CardWith4Color -> true
|
|
||||||
is Card.CardWithColor -> card.color == cardOnBoard.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.PassCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.CardWith4Color -> true
|
|
||||||
is Card.CardWithColor -> card.color == cardOnBoard.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.ChangeColorCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.CardWith4Color -> true
|
|
||||||
is Card.CardWithColor -> card.color == currentColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.Plus2Card -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.Plus2Card -> true
|
|
||||||
::isPlayedLastTurn -> false
|
|
||||||
is Card.CardWith4Color -> true
|
|
||||||
is Card.CardWithColor -> card.color == currentColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.Plus4Card -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.Plus4Card -> true
|
|
||||||
::isPlayedLastTurn -> false
|
|
||||||
is Card.CardWith4Color -> true
|
|
||||||
is Card.CardWithColor -> card.color == currentColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playableCards(playerId: Player.PlayerId): Set<Card> =
|
|
||||||
players
|
|
||||||
.get(playerId)
|
|
||||||
.hand
|
|
||||||
.cards
|
|
||||||
.filter(::canBePlayThisCard)
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
fun playTheCard(
|
|
||||||
playerId: Player.PlayerId,
|
|
||||||
card: Card,
|
|
||||||
chosenColor: Color? = null,
|
|
||||||
): GameStarted =
|
|
||||||
CardIsPlayedEvent(aggregateId, card, playerId, chosenColor, version + 1)
|
|
||||||
.checkPlayerTurn()
|
|
||||||
.checkState({
|
|
||||||
(card is Card.CardWithColor && chosenColor == null) || card is Card.CardWith4Color
|
|
||||||
}, { TheCardIsAColorCardException(playerId) })
|
|
||||||
.checkState({
|
|
||||||
(card is Card.CardWith4Color && chosenColor != null) || card is Card.CardWithColor
|
|
||||||
}, { TheCardHasNoColorException(playerId) })
|
|
||||||
.run(::applyEvent)
|
|
||||||
|
|
||||||
internal fun applyEvent(event: CardIsPlayedEvent): GameStarted =
|
|
||||||
run {
|
|
||||||
val nextDirectionAfterPlay =
|
|
||||||
when (event.card) {
|
|
||||||
is Card.ReverseCard -> direction.revert()
|
|
||||||
else -> direction
|
|
||||||
}
|
|
||||||
|
|
||||||
val color =
|
|
||||||
when (event.card) {
|
|
||||||
is Card.CardWithColor -> event.card.color
|
|
||||||
is Card.CardWith4Color -> event.chosenColor!!
|
|
||||||
}
|
|
||||||
|
|
||||||
copy(
|
|
||||||
players = players.withDropCardOnPlayerHand(event.playerId, event.card),
|
|
||||||
discardPile = discardPile.withNewCard(card = event.card),
|
|
||||||
currentColor = color,
|
|
||||||
lastPlayerId = event.playerId,
|
|
||||||
nextPlayerId = players.nextPlayerTurn(event.playerId, nextDirectionAfterPlay),
|
|
||||||
playedTurnHistory = playedTurnHistory - History(event.playerId, event, direction),
|
|
||||||
direction = nextDirectionAfterPlay,
|
|
||||||
version = event.version,
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playerTakeCartFromDrawPile(
|
|
||||||
playerId: Player.PlayerId,
|
|
||||||
number: Int,
|
|
||||||
): GameStarted {
|
|
||||||
val takenCards = drawPile.take(number).second
|
|
||||||
return PlayerHaveDrawCardEvent(aggregateId, playerId, takenCards, version + 1)
|
|
||||||
.checkPlayerTurn()
|
|
||||||
.checkState({
|
|
||||||
playableCards(playerId).isEmpty()
|
|
||||||
}, {
|
|
||||||
ThePlayerMustPlayACardException(playerId, playableCards(playerId))
|
|
||||||
})
|
|
||||||
.run(::applyEvent)
|
|
||||||
.run {
|
|
||||||
val missingCardsCount = number - takenCards.size
|
|
||||||
if (missingCardsCount > 0) {
|
|
||||||
fillDrawWithDiscard()
|
|
||||||
.playerTakeCartFromDrawPile(playerId, missingCardsCount)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun applyEvent(event: PlayerHaveDrawCardEvent): GameStarted =
|
|
||||||
copy(
|
|
||||||
players = players.withNewCardOnPlayerHand(event.playerId, event.takenCards),
|
|
||||||
drawPile = drawPile.take(event.takenCards.size).first,
|
|
||||||
lastPlayerId = event.playerId,
|
|
||||||
nextPlayerId = players.nextPlayerTurn(event.playerId, direction),
|
|
||||||
version = event.version,
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filling the draw pile with the discard pile while excluding the top card
|
|
||||||
*/
|
|
||||||
private fun fillDrawWithDiscard(): GameStarted =
|
|
||||||
run {
|
|
||||||
val topCard = discardPile.topCard ?: throw InconsistentGameException(this)
|
|
||||||
DrawPile(discardPile.cards - topCard).shuffled() to DiscardPile(setOf(topCard))
|
|
||||||
}.let { (newDrawPile, newDiscardPile) ->
|
|
||||||
DrawFilledWithDiscardEvent(aggregateId, newDrawPile, newDiscardPile, version + 1)
|
|
||||||
.run(::applyEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun applyEvent(event: DrawFilledWithDiscardEvent): GameStarted =
|
|
||||||
copy(
|
|
||||||
drawPile = event.newDrawPile,
|
|
||||||
discardPile = event.newDiscardPile,
|
|
||||||
version = event.version,
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun playerWin(playerId: Player.PlayerId): GameStarted =
|
|
||||||
PlayerWinEvent(aggregateId, playerId, version + 1)
|
|
||||||
.checkState({
|
|
||||||
players
|
|
||||||
.get(playerId)
|
|
||||||
.hand.cards
|
|
||||||
.isEmpty()
|
|
||||||
}, { ThePlayerHasRemainingCardsException(players.get(playerId)) })
|
|
||||||
.checkState({ playerWins.contains(playerId) }, { ThePlayerHasAlreadyWinException(playerId) })
|
|
||||||
.checkState({ !players.map { it.id }.contains(playerId) }, { ThePlayerIsNotInTheGameException(playerId) })
|
|
||||||
.run(::applyEvent)
|
|
||||||
|
|
||||||
internal fun applyEvent(event: PlayerWinEvent): GameStarted =
|
|
||||||
copy(
|
|
||||||
playerWins = playerWins + event.playerId,
|
|
||||||
version = event.version,
|
|
||||||
recordedEvents = recordedEvents + event,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun <T : PlayerActionEvent> T.checkPlayerTurn(): T =
|
|
||||||
checkState(
|
|
||||||
{ nextPlayer.id == playerId },
|
|
||||||
{ ItsNotTheTurnException(playerId, this) },
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun isPlayedLastTurn(card: Card): Boolean =
|
|
||||||
(playedTurnHistory.last().event as? CardIsPlayedEvent)?.card == card
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.injections.application
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
|
|
||||||
import eventDemo.contexts.game.application.reaction.ReactionListener
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
import org.koin.dsl.bind
|
|
||||||
|
|
||||||
fun Module.configureGameDIApplication() {
|
|
||||||
configureDICommandHandlers()
|
|
||||||
|
|
||||||
singleOf(::ReactionListener)
|
|
||||||
singleOf(::EventToNotificationSubscriber)
|
|
||||||
singleOf(::GameEventStoreRepository) bind GameRepository::class
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.injections.application
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.PlayCardHandler
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure all actions
|
|
||||||
*/
|
|
||||||
fun Module.configureDICommandHandlers() {
|
|
||||||
singleOf(::PlayCardHandler)
|
|
||||||
singleOf(::ReadyToPlayHandler)
|
|
||||||
singleOf(::JoinTheGameHandler)
|
|
||||||
singleOf(::TakeCartFromDrawPileHandler)
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
|
|
||||||
import eventDemo.contexts.game.application.notification.CommandSubscriber
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventStore
|
|
||||||
import eventDemo.contexts.game.application.ports.GameProjectionBus
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInPostgresql
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.projections.GameListRepositoryInMemory
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.projections.bus.GameProjectionBusInRabbitMQ
|
|
||||||
import eventDemo.domain.event.projection.GameListRepository
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
import org.koin.dsl.bind
|
|
||||||
|
|
||||||
fun Module.configureGameDIInfrastructure() {
|
|
||||||
singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
|
|
||||||
singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
|
|
||||||
singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class
|
|
||||||
singleOf(::CommandSubscriber)
|
|
||||||
singleOf(::GameChannelsSubscriber)
|
|
||||||
singleOf(::GameCommandHandlerDispatcher)
|
|
||||||
singleOf(::GameListRepositoryInMemory) bind GameListRepository::class
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.ktor
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.infrastructure.configuration.listener.configureProjectionListener
|
|
||||||
import eventDemo.contexts.game.infrastructure.configuration.listener.configureReactionListener
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import org.koin.ktor.plugin.koin
|
|
||||||
|
|
||||||
fun Application.configureUno() {
|
|
||||||
configureSerialization()
|
|
||||||
|
|
||||||
configureWebSockets()
|
|
||||||
declareWebSocketsRoute()
|
|
||||||
|
|
||||||
configureHttpRouting()
|
|
||||||
declareHttpGameRoute()
|
|
||||||
|
|
||||||
koin().run {
|
|
||||||
configureProjectionListener()
|
|
||||||
configureReactionListener()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.ktor
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.infrastructure.rest.gamesListRoute
|
|
||||||
import eventDemo.contexts.game.infrastructure.rest.getFullNotificationsRoute
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import org.koin.ktor.ext.get as getDi
|
|
||||||
|
|
||||||
fun Application.declareHttpGameRoute() {
|
|
||||||
routing {
|
|
||||||
gamesListRoute(getDi())
|
|
||||||
getFullNotificationsRoute(getDi(), getDi())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.ktor
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.infrastructure.websocket.gameWebSocket
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import org.koin.ktor.ext.get as getDi
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
fun Application.declareWebSocketsRoute() {
|
|
||||||
routing {
|
|
||||||
gameWebSocket(
|
|
||||||
getDi(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.listener
|
|
||||||
|
|
||||||
import eventDemo.domain.event.projection.GameListRepository
|
|
||||||
import org.koin.core.Koin
|
|
||||||
|
|
||||||
fun Koin.configureProjectionListener() {
|
|
||||||
get<GameListRepository>()
|
|
||||||
.subscribeToBus()
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.configuration.listener
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.reaction.ReactionListener
|
|
||||||
import org.koin.core.Koin
|
|
||||||
|
|
||||||
fun Koin.configureReactionListener() {
|
|
||||||
get<ReactionListener>()
|
|
||||||
.subscribeToBus()
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.persistence.eventStore
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventStore
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStore
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream to publish and read the played card event.
|
|
||||||
*/
|
|
||||||
class GameEventStoreInMemory :
|
|
||||||
GameEventStore,
|
|
||||||
EventStore<GameEvent, GameId> by EventStoreInMemory()
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.persistence.eventStore
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventStore
|
|
||||||
import eventDemo.contexts.game.domain.events.GameEvent
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStore
|
|
||||||
import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
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) },
|
|
||||||
"game.game_event_stream",
|
|
||||||
)
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.persistence.projections
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventStore
|
|
||||||
import eventDemo.contexts.game.application.ports.GameProjectionBus
|
|
||||||
import eventDemo.contexts.game.application.projections.applyEvent
|
|
||||||
import eventDemo.domain.event.projection.GameListRepository
|
|
||||||
import eventDemo.shared.game.projection.GameList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
|
|
||||||
*/
|
|
||||||
class GameListRepositoryInMemory(
|
|
||||||
val gameEventStore: GameEventStore,
|
|
||||||
val projectionBus: GameProjectionBus,
|
|
||||||
val eventBus: GameEventBus,
|
|
||||||
) : GameListRepository {
|
|
||||||
val projections: MutableMap<GameId, GameList> = mutableMapOf()
|
|
||||||
|
|
||||||
override fun getList(
|
|
||||||
limit: Int,
|
|
||||||
offset: Int,
|
|
||||||
): List<GameList> =
|
|
||||||
projections
|
|
||||||
.values
|
|
||||||
.drop(offset)
|
|
||||||
.take(limit)
|
|
||||||
|
|
||||||
override fun save(gameList: GameList) {
|
|
||||||
projections[gameList.aggregateId] = gameList
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun subscribeToBus() {
|
|
||||||
// On new event was received, build projection and publish it to the projection bus
|
|
||||||
eventBus.subscribe { event ->
|
|
||||||
withLoggingContext("event" to event.toString()) {
|
|
||||||
gameEventStore
|
|
||||||
.getStream(event.aggregateId)
|
|
||||||
.readAll()
|
|
||||||
.fold(GameList(event.aggregateId)) { acc, event ->
|
|
||||||
acc.applyEvent(event)
|
|
||||||
}.also { save(it) }
|
|
||||||
.also { projectionBus.publish(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.rest
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.application.notification.toNotification
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventStore
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.serializers.GameIdSerializer
|
|
||||||
import eventDemo.sharedKernel.currentUserId
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API routes to read the game state.
|
|
||||||
*/
|
|
||||||
fun Route.getFullNotificationsRoute(
|
|
||||||
gameRepository: GameRepository,
|
|
||||||
gameEventStore: GameEventStore,
|
|
||||||
) {
|
|
||||||
authenticate {
|
|
||||||
get<Game> { body ->
|
|
||||||
val game =
|
|
||||||
gameRepository.get(body.id)
|
|
||||||
?: return@get call.respond(HttpStatusCode.NotFound)
|
|
||||||
val notifications =
|
|
||||||
gameEventStore
|
|
||||||
.getStream(body.id)
|
|
||||||
.readAll()
|
|
||||||
.flatMap { it.toNotification(game, call.currentUserId) }
|
|
||||||
|
|
||||||
call.respond(notifications)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.contexts.game.infrastructure.websocket
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
|
|
||||||
import eventDemo.libs.helpers.fromFrameChannel
|
|
||||||
import eventDemo.libs.helpers.toObjectChannel
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.sharedKernel.currentUserId
|
|
||||||
import io.ktor.server.auth.authenticate
|
|
||||||
import io.ktor.server.routing.Route
|
|
||||||
import io.ktor.server.websocket.webSocket
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
@DelicateCoroutinesApi
|
|
||||||
fun Route.gameWebSocket(channelSubscriber: GameChannelsSubscriber) {
|
|
||||||
authenticate {
|
|
||||||
webSocket("/games/{id}") {
|
|
||||||
channelSubscriber.subscribePlayerToGameChannels(
|
|
||||||
gameId = GameId(Uuid.parse(call.parameters["id"]!!)),
|
|
||||||
userId = call.currentUserId,
|
|
||||||
incomingCommandChannel = toObjectChannel(incoming),
|
|
||||||
sendNotificationChannel = fromFrameChannel(outgoing),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package eventDemo.libs.eventSource
|
|
||||||
|
|
||||||
import eventDemo.shared.ids.AggregateId
|
|
||||||
import eventDemo.shared.ids.EventId
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The basic interface for an Event
|
|
||||||
* @see eventDemo.libs.eventSource.eventStore.EventStream
|
|
||||||
*/
|
|
||||||
interface Event<ID : AggregateId> {
|
|
||||||
val eventId: EventId
|
|
||||||
val aggregateId: ID
|
|
||||||
val createdAt: Instant
|
|
||||||
val version: Int
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package eventDemo.libs.eventSource.eventStore
|
|
||||||
|
|
||||||
import eventDemo.libs.eventSource.Event
|
|
||||||
import eventDemo.shared.ids.AggregateId
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
interface EventStore<E : Event<ID>, ID : AggregateId> {
|
|
||||||
fun getStream(aggregateId: ID): EventStream<E, ID>
|
|
||||||
|
|
||||||
@Throws(VersionConflictException::class)
|
|
||||||
fun append(event: E) =
|
|
||||||
withLoggingContext("event" to event.toString()) {
|
|
||||||
getStream(event.aggregateId).append(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(VersionConflictException::class)
|
|
||||||
fun append(events: Set<E>) =
|
|
||||||
events.forEach { append(it) }
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package eventDemo.sharedKernel
|
|
||||||
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import io.ktor.server.application.ApplicationCall
|
|
||||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
|
||||||
import io.ktor.server.auth.principal
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
|
|
||||||
internal val ApplicationCall.currentUserId: UserId
|
|
||||||
get() =
|
|
||||||
principal<JWTPrincipal>()!!.run {
|
|
||||||
UserId(Uuid.parse(payload.getClaim("userid").asString()))
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package eventDemo.architecture
|
|
||||||
|
|
||||||
import com.tngtech.archunit.core.importer.ClassFileImporter
|
|
||||||
import com.tngtech.archunit.core.importer.ImportOption
|
|
||||||
import com.tngtech.archunit.library.Architectures.layeredArchitecture
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters),
|
|
||||||
* pour chaque bounded context sous `eventDemo.contexts`.
|
|
||||||
*
|
|
||||||
* Les contexts ne sont pas listés en dur : ils sont déduits des classes réellement
|
|
||||||
* présentes sous `eventDemo.contexts.*`, de sorte que l'ajout d'un nouveau context
|
|
||||||
* (nouveau dossier `eventDemo.contexts.<xxx>`) soit automatiquement couvert par ce test,
|
|
||||||
* sans modification de ce fichier.
|
|
||||||
*
|
|
||||||
* Convention attendue, pour un contexte donné :
|
|
||||||
* eventDemo.contexts.<context>.domain
|
|
||||||
* eventDemo.contexts.<context>.application
|
|
||||||
* eventDemo.contexts.<context>.infrastructure
|
|
||||||
*
|
|
||||||
* Règles imposées :
|
|
||||||
* – domain → ne dépend d'aucune autre couche (ni application, ni infrastructure)
|
|
||||||
* – application → ne dépend que de domain (jamais d'infrastructure)
|
|
||||||
* – infrastructure → ne dépend que de domain et application
|
|
||||||
*/
|
|
||||||
class HexagonalArchitectureTest {
|
|
||||||
private val rootPackage = "eventDemo.contexts"
|
|
||||||
|
|
||||||
private val classes =
|
|
||||||
ClassFileImporter()
|
|
||||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
|
||||||
.importPackages(rootPackage)
|
|
||||||
|
|
||||||
// Premier segment de package après "eventDemo.contexts." (ex. "auth", "game", ...),
|
|
||||||
// recalculé à chaque exécution à partir des classes importées.
|
|
||||||
private val contexts: Set<String> =
|
|
||||||
classes
|
|
||||||
.map { it.packageName }
|
|
||||||
.filter { it.startsWith("$rootPackage.") }
|
|
||||||
.map { it.removePrefix("$rootPackage.").substringBefore('.') }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `respecte les couches de l'architecture hexagonale`() {
|
|
||||||
check(contexts.isNotEmpty()) {
|
|
||||||
"Aucun context trouvé sous `$rootPackage` : le test ne vérifie rien, " +
|
|
||||||
"vérifiez que le package racine est correct."
|
|
||||||
}
|
|
||||||
|
|
||||||
contexts.forEach { context ->
|
|
||||||
val basePackage = "$rootPackage.$context"
|
|
||||||
|
|
||||||
@Suppress("ktlint:standard:chain-method-continuation")
|
|
||||||
layeredArchitecture()
|
|
||||||
.consideringAllDependencies()
|
|
||||||
.layer("Domain").definedBy("$basePackage.domain..")
|
|
||||||
.layer("Application").definedBy("$basePackage.application..")
|
|
||||||
.layer("Infrastructure").definedBy("$basePackage.infrastructure..")
|
|
||||||
.whereLayer("Domain").mayNotAccessAnyLayer()
|
|
||||||
.whereLayer("Application").mayOnlyAccessLayers("Domain")
|
|
||||||
.whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application")
|
|
||||||
.check(classes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package eventDemo.contexts.auth.domain
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import org.junit.jupiter.api.assertNotNull
|
|
||||||
|
|
||||||
class UserTest :
|
|
||||||
FunSpec({
|
|
||||||
|
|
||||||
test("Create User") {
|
|
||||||
User.createNewUser("Bob", "changeit").run {
|
|
||||||
username shouldBe "Bob"
|
|
||||||
password shouldBe "changeit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Create User With event") {
|
|
||||||
User
|
|
||||||
.loadFromHistory(
|
|
||||||
setOf(
|
|
||||||
NewUserCreatedEvent(
|
|
||||||
"Bob",
|
|
||||||
"changeit",
|
|
||||||
version = 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).run {
|
|
||||||
assertNotNull(this)
|
|
||||||
username shouldBe "Bob"
|
|
||||||
password shouldBe "changeit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application
|
|
||||||
|
|
||||||
import eventDemo.Tag
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.disableRandomForTest
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.command.GameCommand
|
|
||||||
import eventDemo.shared.game.notification.ItsTheTurnOfNotification
|
|
||||||
import eventDemo.shared.game.notification.Notification
|
|
||||||
import eventDemo.shared.game.notification.PlayerAsJoinTheGameNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerWasReadyNotification
|
|
||||||
import eventDemo.shared.game.notification.TheGameWasStartedNotification
|
|
||||||
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.createGameWithCommandsInChannels
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.joinTheGame
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.playCard
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.readyToPlay
|
|
||||||
import eventDemo.testHelpers.createNewUser
|
|
||||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
|
||||||
import io.kotest.assertions.nondeterministic.until
|
|
||||||
import io.kotest.assertions.retry
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.collections.shouldContainExactly
|
|
||||||
import io.kotest.matchers.equals.shouldBeEqual
|
|
||||||
import io.kotest.matchers.equals.shouldEqual
|
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
|
||||||
import io.kotest.matchers.should
|
|
||||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.joinAll
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import org.junit.jupiter.api.assertInstanceOf
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
@DelicateCoroutinesApi
|
|
||||||
class GameSimulationTest :
|
|
||||||
FunSpec({
|
|
||||||
tags(Tag.Postgresql)
|
|
||||||
|
|
||||||
test("Simulation of a game") {
|
|
||||||
should {
|
|
||||||
retry(maxRetry = 3, timeout = 20.seconds) {
|
|
||||||
disableRandomForTest()
|
|
||||||
val gameId = GameId()
|
|
||||||
val user1 = createNewUser("user1")
|
|
||||||
val user2 = createNewUser("user2")
|
|
||||||
|
|
||||||
val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED)
|
|
||||||
val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED)
|
|
||||||
val channelNotification1 = Channel<Notification>(Channel.BUFFERED)
|
|
||||||
val channelNotification2 = Channel<Notification>(Channel.BUFFERED)
|
|
||||||
|
|
||||||
var playedCard1: Card? = null
|
|
||||||
var playedCard2: Card? = null
|
|
||||||
|
|
||||||
var player1HasJoin = false
|
|
||||||
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
val gameRepository = get<GameRepository>()
|
|
||||||
val userRepository = get<UserRepository>()
|
|
||||||
userRepository.run {
|
|
||||||
save(user1)
|
|
||||||
save(user2)
|
|
||||||
}
|
|
||||||
|
|
||||||
gameRepository.create(gameId)
|
|
||||||
|
|
||||||
// Run command/notification subscriber
|
|
||||||
// In the normal process, these subscriber is invoque on players connect to the websocket
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
get<GameChannelsSubscriber>().subscribePlayerToGameChannels(
|
|
||||||
gameId,
|
|
||||||
user1.id,
|
|
||||||
channelCommand1,
|
|
||||||
channelNotification1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
get<GameChannelsSubscriber>().subscribePlayerToGameChannels(
|
|
||||||
gameId,
|
|
||||||
user2.id,
|
|
||||||
channelCommand2,
|
|
||||||
channelNotification2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume etch notification of players, and put theses in a list.
|
|
||||||
// Is used later to control when other players can execute the next action
|
|
||||||
val player1Notifications = mutableListOf<Notification>()
|
|
||||||
val player2Notifications = mutableListOf<Notification>()
|
|
||||||
run {
|
|
||||||
GlobalScope.launch {
|
|
||||||
for (notification in channelNotification1) {
|
|
||||||
player1Notifications.add(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GlobalScope.launch {
|
|
||||||
for (notification in channelNotification2) {
|
|
||||||
player2Notifications.add(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Player 1 actions
|
|
||||||
val player1Job =
|
|
||||||
launch {
|
|
||||||
createGameWithCommandsInChannels(channelCommand1, gameId, user1) {
|
|
||||||
|
|
||||||
joinTheGame()
|
|
||||||
player1Notifications.waitNotification<WelcomeToTheGameNotification> {
|
|
||||||
players.map { it.userId }.contains(user1.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
player1HasJoin = true
|
|
||||||
|
|
||||||
player1Notifications.waitNotification<PlayerAsJoinTheGameNotification> {
|
|
||||||
player.userId == user2.id
|
|
||||||
}
|
|
||||||
|
|
||||||
readyToPlay()
|
|
||||||
player1Notifications.waitNotification<PlayerWasReadyNotification> {
|
|
||||||
playerId == getPlayer(user2).id
|
|
||||||
}
|
|
||||||
|
|
||||||
playedCard1 =
|
|
||||||
player1Notifications
|
|
||||||
.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
|
|
||||||
.hand
|
|
||||||
.first()
|
|
||||||
.apply {
|
|
||||||
this.shouldBeInstanceOf<Card.NumericCard>()
|
|
||||||
number shouldEqual 1
|
|
||||||
color shouldEqual Card.Color.Red
|
|
||||||
}
|
|
||||||
|
|
||||||
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
|
|
||||||
if (player.userId == user2.id) error("WRONG PLAYER TURN")
|
|
||||||
player.userId == user1.id
|
|
||||||
}
|
|
||||||
|
|
||||||
game
|
|
||||||
.shouldBeInstanceOf<GameStarted>()
|
|
||||||
.discardPile
|
|
||||||
.topCard
|
|
||||||
.shouldNotBeNull()
|
|
||||||
.shouldBeInstanceOf<Card.NumericCard> {
|
|
||||||
it.number shouldEqual 0
|
|
||||||
it.color shouldEqual Card.Color.Red
|
|
||||||
}
|
|
||||||
|
|
||||||
playCard(playedCard1!!)
|
|
||||||
|
|
||||||
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
|
|
||||||
player == getPlayer(user2)
|
|
||||||
}
|
|
||||||
|
|
||||||
player1Notifications.waitNotification<PlayerAsPlayACardNotification> {
|
|
||||||
playerId == getPlayer(user2).id && card == playedCard2
|
|
||||||
}
|
|
||||||
|
|
||||||
playedCard1 =
|
|
||||||
assertInstanceOf<GameStarted>(game)
|
|
||||||
.playableCards(currentPlayer.id)
|
|
||||||
.first()
|
|
||||||
|
|
||||||
playedCard1.run {
|
|
||||||
this.shouldBeInstanceOf<Card.NumericCard>()
|
|
||||||
number shouldEqual 2
|
|
||||||
color shouldEqual Card.Color.Red
|
|
||||||
}
|
|
||||||
|
|
||||||
playCard(playedCard1)
|
|
||||||
|
|
||||||
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
|
|
||||||
player == getPlayer(user2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Player 2 actions
|
|
||||||
val player2Job =
|
|
||||||
launch {
|
|
||||||
createGameWithCommandsInChannels(channelCommand2, gameId, user2) {
|
|
||||||
// wait player 1 has joined the game
|
|
||||||
until(3.seconds) { player1HasJoin }
|
|
||||||
|
|
||||||
joinTheGame()
|
|
||||||
|
|
||||||
player2Notifications.waitNotification<WelcomeToTheGameNotification> {
|
|
||||||
players.map { it.userId }.contains(user1.id) &&
|
|
||||||
players.map { it.userId }.contains(user2.id)
|
|
||||||
}
|
|
||||||
player2Notifications.waitNotification<PlayerWasReadyNotification> { playerId == getPlayer(user1).id }
|
|
||||||
|
|
||||||
readyToPlay()
|
|
||||||
|
|
||||||
playedCard2 =
|
|
||||||
player2Notifications
|
|
||||||
.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
|
|
||||||
.hand
|
|
||||||
.first()
|
|
||||||
.apply {
|
|
||||||
this.shouldBeInstanceOf<Card.NumericCard>()
|
|
||||||
number shouldEqual 8
|
|
||||||
color shouldEqual Card.Color.Red
|
|
||||||
}
|
|
||||||
|
|
||||||
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
|
|
||||||
if (player.userId == user2.id) error("WRONG PLAYER TURN")
|
|
||||||
player.userId == user1.id
|
|
||||||
}
|
|
||||||
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
|
|
||||||
playerId == getPlayer(user1).id && card == playedCard1
|
|
||||||
}
|
|
||||||
|
|
||||||
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
|
|
||||||
player == currentPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
game
|
|
||||||
.shouldBeInstanceOf<GameStarted>()
|
|
||||||
.discardPile
|
|
||||||
.topCard
|
|
||||||
.shouldNotBeNull()
|
|
||||||
.shouldBeInstanceOf<Card.NumericCard> {
|
|
||||||
it.number shouldEqual 1
|
|
||||||
it.color shouldEqual Card.Color.Red
|
|
||||||
}
|
|
||||||
|
|
||||||
playCard(playedCard2)
|
|
||||||
|
|
||||||
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
|
|
||||||
player.userId == user1.id
|
|
||||||
}
|
|
||||||
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
|
|
||||||
playerId == currentPlayer.id && card == playedCard2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait the end of the game
|
|
||||||
joinAll(player1Job, player2Job)
|
|
||||||
|
|
||||||
// Build the last state from the event store
|
|
||||||
val game = gameRepository.get(gameId)
|
|
||||||
assertInstanceOf<GameStarted>(game)
|
|
||||||
|
|
||||||
// Check if the state is correct
|
|
||||||
game.aggregateId shouldBeEqual gameId
|
|
||||||
game.players.map { it.userId } shouldContainExactly setOf(user1.id, user2.id)
|
|
||||||
assertNotNull(game.players.find { it.userId == user1.id })
|
|
||||||
.hand.size shouldBeEqual 5
|
|
||||||
assertNotNull(game.players.find { it.userId == user2.id })
|
|
||||||
.hand.size shouldBeEqual 6
|
|
||||||
game.direction shouldBeEqual Game.Direction.CLOCKWISE
|
|
||||||
assertNotNull(game.lastPlayer?.userId) shouldBeEqual user1.id
|
|
||||||
assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
context(user: User)
|
|
||||||
private suspend inline fun <reified T : Notification> MutableList<Notification>.waitNotification(crossinline block: T.() -> Boolean): T {
|
|
||||||
println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}")
|
|
||||||
return eventually(5.seconds) {
|
|
||||||
filterIsInstance<T>()
|
|
||||||
.first { block(it) }
|
|
||||||
.also { remove(it) }
|
|
||||||
}.also { println("NOTIFICATION RECEIVED: ${T::class.simpleName} for user: ${user.username}") }
|
|
||||||
}
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.eventStore
|
|
||||||
|
|
||||||
import ch.qos.logback.classic.Level
|
|
||||||
import com.rabbitmq.client.impl.ForgivingExceptionHandler
|
|
||||||
import eventDemo.Tag
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsHelpers
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsHelpers.joinTheGame
|
|
||||||
import eventDemo.testHelpers.createNewUser
|
|
||||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
|
||||||
import eventDemo.testHelpers.withLogLevel
|
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
|
||||||
import io.kotest.assertions.nondeterministic.eventuallyConfig
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.collections.shouldHaveSize
|
|
||||||
import io.kotest.matchers.equals.shouldBeEqual
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.joinAll
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.slf4j.Logger
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
class GameEventStoreRepositoryTest :
|
|
||||||
FunSpec({
|
|
||||||
tags(Tag.Postgresql)
|
|
||||||
|
|
||||||
val user1 = createNewUser("user1")
|
|
||||||
val user2 = createNewUser("user2")
|
|
||||||
|
|
||||||
test("GameRepository should build return the game after dispatch commands") {
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
get<UserRepository>().run {
|
|
||||||
save(user1)
|
|
||||||
}
|
|
||||||
CreateGameWithCommandsHelpers.createGameWithCommands {
|
|
||||||
user1.joinTheGame()
|
|
||||||
getPlayer(user1).userId shouldBeEqual user1.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("get should build the last version of the state") {
|
|
||||||
withLogLevel(
|
|
||||||
ForgivingExceptionHandler::class.java.name to Level.OFF,
|
|
||||||
) {
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
val repo = get<GameEventStoreRepository>()
|
|
||||||
get<UserRepository>().run {
|
|
||||||
save(user1)
|
|
||||||
save(user2)
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateGameWithCommandsHelpers.createGameWithCommands {
|
|
||||||
user1.joinTheGame()
|
|
||||||
assertNotNull(repo.get(gameId)).run {
|
|
||||||
players.isNotEmpty() shouldBeEqual true
|
|
||||||
players.get(user1.id).userId shouldBeEqual user1.id
|
|
||||||
}
|
|
||||||
user2.joinTheGame()
|
|
||||||
assertNotNull(repo.get(gameId)).run {
|
|
||||||
players.isNotEmpty() shouldBeEqual true
|
|
||||||
players.size shouldBeEqual 2
|
|
||||||
players.get(user1.id).userId shouldBeEqual user1.id
|
|
||||||
players.get(user2.id).userId shouldBeEqual user2.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("get should be concurrently secure").config(tags = setOf(Tag.Concurrence)) {
|
|
||||||
withLogLevel(
|
|
||||||
Logger.ROOT_LOGGER_NAME to Level.ERROR,
|
|
||||||
ForgivingExceptionHandler::class.java.name to Level.OFF,
|
|
||||||
) {
|
|
||||||
var aggregateIds: MutableList<GameId> = mutableListOf()
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
val repo = get<GameRepository>()
|
|
||||||
|
|
||||||
val gameName = "testGame${UUID.randomUUID()}"
|
|
||||||
(1..2)
|
|
||||||
.map { treadN ->
|
|
||||||
GlobalScope
|
|
||||||
.launch {
|
|
||||||
CreateGameWithCommandsHelpers.createGameWithCommands("testGame $treadN") {
|
|
||||||
repeat(3) { userN ->
|
|
||||||
val userX = createNewUser("userX $treadN:$userN")
|
|
||||||
get<UserRepository>().save(userX)
|
|
||||||
userX.joinTheGame()
|
|
||||||
}
|
|
||||||
aggregateIds.add(gameId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.joinAll()
|
|
||||||
|
|
||||||
eventually(
|
|
||||||
eventuallyConfig {
|
|
||||||
duration = 5.seconds
|
|
||||||
interval = 1.seconds
|
|
||||||
includeFirst = false
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
aggregateIds shouldHaveSize 2
|
|
||||||
aggregateIds.forEach {
|
|
||||||
assertNotNull(repo.get(it)).run {
|
|
||||||
version shouldBeEqual 4
|
|
||||||
players shouldHaveSize 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.notification
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInMemory
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.JoinTheGameHandler
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.PlayCardHandler
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.ReadyToPlayHandler
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.TakeCartFromDrawPileHandler
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameEventStoreRepository
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.eventStore.GameEventStoreInMemory
|
|
||||||
import eventDemo.shared.game.command.JoinTheGameCommand
|
|
||||||
import eventDemo.shared.game.notification.Notification
|
|
||||||
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import eventDemo.testHelpers.createNewUser
|
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.collections.shouldContain
|
|
||||||
import io.kotest.matchers.equals.shouldEqual
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.junit.jupiter.api.assertInstanceOf
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class EventToNotificationSubscriberTest :
|
|
||||||
FunSpec({
|
|
||||||
|
|
||||||
test("When event when send to the bus, a notification should be published") {
|
|
||||||
val bus = GameEventBusInMemory()
|
|
||||||
val gameRepository = GameEventStoreRepository(GameEventStoreInMemory())
|
|
||||||
val userRepository = UserEventStoreRepository(UserEventStoreInMemory())
|
|
||||||
val subscriber =
|
|
||||||
EventToNotificationSubscriber(
|
|
||||||
bus,
|
|
||||||
gameRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
val commentDispatcher =
|
|
||||||
GameCommandHandlerDispatcher(
|
|
||||||
PlayCardHandler(gameRepository, bus),
|
|
||||||
ReadyToPlayHandler(gameRepository, bus),
|
|
||||||
JoinTheGameHandler(gameRepository, bus, userRepository),
|
|
||||||
TakeCartFromDrawPileHandler(gameRepository, bus),
|
|
||||||
)
|
|
||||||
val notificationChannel = Channel<Notification>(Channel.BUFFERED)
|
|
||||||
|
|
||||||
val game = gameRepository.create()
|
|
||||||
|
|
||||||
val user1 = createNewUser("user1")
|
|
||||||
val user2 = createNewUser("user2")
|
|
||||||
userRepository.run {
|
|
||||||
save(user1)
|
|
||||||
save(user2)
|
|
||||||
}
|
|
||||||
|
|
||||||
val player1Notifications = mutableListOf<Notification>()
|
|
||||||
GlobalScope.launch {
|
|
||||||
for (notification in notificationChannel) {
|
|
||||||
player1Notifications.add(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commentDispatcher.dispatch(JoinTheGameCommand(user1.id, JoinTheGameCommand.Payload(game.aggregateId)))
|
|
||||||
subscriber
|
|
||||||
.subscribeToEventsAndSendNotification(
|
|
||||||
game.aggregateId,
|
|
||||||
user2.id,
|
|
||||||
notificationChannel,
|
|
||||||
).use {
|
|
||||||
commentDispatcher.dispatch(JoinTheGameCommand(user2.id, JoinTheGameCommand.Payload(game.aggregateId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
eventually(duration = 1.seconds) {
|
|
||||||
player1Notifications.size shouldEqual 1
|
|
||||||
}
|
|
||||||
player1Notifications.first().let { notification ->
|
|
||||||
assertInstanceOf<WelcomeToTheGameNotification>(notification)
|
|
||||||
notification.players.map { it.userId } shouldContain user2.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
-301
@@ -1,301 +0,0 @@
|
|||||||
package eventDemo.contexts.game.application.notification
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.GameStartedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerReadyEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.DiscardPile
|
|
||||||
import eventDemo.shared.game.DrawPile
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerHand
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.game.notification.ItsTheTurnOfNotification
|
|
||||||
import eventDemo.shared.game.notification.PilesShuffledNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerHavePassNotification
|
|
||||||
import eventDemo.shared.game.notification.PlayerWasReadyNotification
|
|
||||||
import eventDemo.shared.game.notification.TheGameWasStartedNotification
|
|
||||||
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
|
|
||||||
import eventDemo.shared.game.notification.YourNewCardNotification
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import org.junit.jupiter.api.assertInstanceOf
|
|
||||||
|
|
||||||
class ToNotificationTest :
|
|
||||||
FunSpec({
|
|
||||||
val player1 =
|
|
||||||
Player(
|
|
||||||
name = "Bob",
|
|
||||||
userId = UserId(),
|
|
||||||
hand = PlayerHand(setOf(Card.NumericCard(1, Card.Color.Red))),
|
|
||||||
id = Player.PlayerId(),
|
|
||||||
)
|
|
||||||
val player2 =
|
|
||||||
Player(
|
|
||||||
name = "John",
|
|
||||||
userId = UserId(),
|
|
||||||
hand = PlayerHand(setOf(Card.NumericCard(1, Card.Color.Red))),
|
|
||||||
id = Player.PlayerId(),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("NewPlayerEvent") {
|
|
||||||
val game =
|
|
||||||
GameCreated(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
version = 1,
|
|
||||||
players = PlayerList(setOf(player1)),
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
NewPlayerEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
player = player1,
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).let {
|
|
||||||
it.toList().size shouldBe 1
|
|
||||||
// Check if the user is
|
|
||||||
assertInstanceOf<WelcomeToTheGameNotification>(it.first()).run {
|
|
||||||
players.size shouldBe 1
|
|
||||||
players.first().name shouldBe "Bob"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerReadyEvent") {
|
|
||||||
val game =
|
|
||||||
GameCreated(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
version = 1,
|
|
||||||
players = PlayerList(setOf(player1)),
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
PlayerReadyEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
playerId = player1.id,
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).let {
|
|
||||||
it.toList().size shouldBe 1
|
|
||||||
assertInstanceOf<PlayerWasReadyNotification>(it.first()).let {
|
|
||||||
it.playerId shouldBe player1.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerHaveDrawCardEvent on current player") {
|
|
||||||
val game =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
currentColor = Card.Color.Red,
|
|
||||||
version = 1,
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
val card = Card.NumericCard(1, Card.Color.Blue)
|
|
||||||
PlayerHaveDrawCardEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
playerId = player1.id,
|
|
||||||
takenCards = setOf(card),
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).let {
|
|
||||||
it.toList().size shouldBe 2
|
|
||||||
it.toList().let { notifications ->
|
|
||||||
assertInstanceOf<YourNewCardNotification>(notifications.first()).let {
|
|
||||||
it.cards.first() shouldBe card
|
|
||||||
}
|
|
||||||
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
|
|
||||||
it.player.id shouldBe player2.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test("PlayerHaveDrawCardEvent on other player") {
|
|
||||||
val game =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
currentColor = Card.Color.Red,
|
|
||||||
version = 1,
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
val card = Card.NumericCard(1, Card.Color.Blue)
|
|
||||||
PlayerHaveDrawCardEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
playerId = player1.id,
|
|
||||||
takenCards = setOf(card),
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player2.userId,
|
|
||||||
).let {
|
|
||||||
it.toList().size shouldBe 2
|
|
||||||
it.toList().let { notifications ->
|
|
||||||
assertInstanceOf<PlayerHavePassNotification>(notifications.first()).let {
|
|
||||||
it.playerId shouldBe player1.id
|
|
||||||
}
|
|
||||||
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
|
|
||||||
it.player.id shouldBe player2.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("CardIsPlayedEvent on current player") {
|
|
||||||
val game =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
lastPlayerId = player2.id,
|
|
||||||
nextPlayerId = player1.id,
|
|
||||||
currentColor = Card.Color.Red,
|
|
||||||
version = 1,
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
val card = Card.NumericCard(1, Card.Color.Blue)
|
|
||||||
CardIsPlayedEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
playerId = player1.id,
|
|
||||||
card = card,
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).toList()
|
|
||||||
.let { notifications ->
|
|
||||||
notifications.size shouldBe 2
|
|
||||||
assertInstanceOf<PlayerAsPlayACardNotification>(notifications.first()).let {
|
|
||||||
it.playerId shouldBe player1.id
|
|
||||||
it.card shouldBe card
|
|
||||||
}
|
|
||||||
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
|
|
||||||
it.player.id shouldBe player1.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test("CardIsPlayedEvent on other player") {
|
|
||||||
val game =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
currentColor = Card.Color.Red,
|
|
||||||
version = 1,
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
val card = Card.NumericCard(1, Card.Color.Blue)
|
|
||||||
CardIsPlayedEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
playerId = player2.id,
|
|
||||||
card = card,
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).let {
|
|
||||||
it.toList().size shouldBe 2
|
|
||||||
it.toList().let { notifications ->
|
|
||||||
assertInstanceOf<PlayerAsPlayACardNotification>(notifications.first()).let {
|
|
||||||
it.playerId shouldBe player2.id
|
|
||||||
it.card shouldBe card
|
|
||||||
}
|
|
||||||
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
|
|
||||||
it.player.id shouldBe player2.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("DrawFilledWithDiscardEvent") {
|
|
||||||
val game =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
currentColor = Card.Color.Red,
|
|
||||||
version = 1,
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
DrawFilledWithDiscardEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
newDrawPile = DrawPile(),
|
|
||||||
newDiscardPile = DiscardPile(),
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).let {
|
|
||||||
it.toList().size shouldBe 1
|
|
||||||
assertInstanceOf<PilesShuffledNotification>(it.first())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("GameStartedEvent") {
|
|
||||||
val game =
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
currentColor = Card.Color.Red,
|
|
||||||
version = 1,
|
|
||||||
recordedEvents = setOf(),
|
|
||||||
)
|
|
||||||
GameStartedEvent(
|
|
||||||
game.aggregateId,
|
|
||||||
version = 2,
|
|
||||||
firstPlayer = player1.id,
|
|
||||||
playersHans =
|
|
||||||
mapOf(
|
|
||||||
player1.id to player1.hand,
|
|
||||||
player2.id to player2.hand,
|
|
||||||
),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
).toNotification(
|
|
||||||
game = game,
|
|
||||||
currentUserId = player1.userId,
|
|
||||||
).toList()
|
|
||||||
.let { notifications ->
|
|
||||||
notifications.size shouldBe 2
|
|
||||||
assertInstanceOf<TheGameWasStartedNotification>(notifications.first()).let {
|
|
||||||
it.hand.size shouldBe 1
|
|
||||||
it.hand.first() shouldBe player1.hand.cards.first()
|
|
||||||
}
|
|
||||||
assertInstanceOf<ItsTheTurnOfNotification>(notifications[1]).let {
|
|
||||||
it.player.id shouldBe player2.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.PlayerHand
|
|
||||||
import eventDemo.testHelpers.act
|
|
||||||
import eventDemo.testHelpers.arrange
|
|
||||||
import eventDemo.testHelpers.assert
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.ints.shouldBeExactly
|
|
||||||
import org.junit.jupiter.api.assertInstanceOf
|
|
||||||
|
|
||||||
class PlayerHandTest :
|
|
||||||
FunSpec({
|
|
||||||
test("can be add new card to the hand") {
|
|
||||||
arrange {
|
|
||||||
PlayerHand(
|
|
||||||
setOf(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.NumericCard(1, Card.Color.Red),
|
|
||||||
Card.NumericCard(2, Card.Color.Red),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.act { hand ->
|
|
||||||
hand.withNewCards(setOf(Card.NumericCard(3, Card.Color.Red)))
|
|
||||||
}.assert { hand ->
|
|
||||||
hand.size shouldBeExactly 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can be remove card to the hand") {
|
|
||||||
arrange {
|
|
||||||
PlayerHand(
|
|
||||||
setOf(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.NumericCard(1, Card.Color.Red),
|
|
||||||
Card.NumericCard(2, Card.Color.Red),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.act { hand ->
|
|
||||||
hand.withoutTheCards(setOf(hand.cards.elementAt(0)))
|
|
||||||
}.assert { hand ->
|
|
||||||
hand.size shouldBeExactly 2
|
|
||||||
assertInstanceOf<Set<Card.NumericCard>>(hand.cards)
|
|
||||||
assertInstanceOf<Card.NumericCard>(hand.cards.elementAt(0)).number shouldBeExactly 1
|
|
||||||
assertInstanceOf<Card.NumericCard>(hand.cards.elementAt(1)).number shouldBeExactly 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
-389
@@ -1,389 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
|
||||||
import eventDemo.contexts.game.domain.events.PlayerWinEvent
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game.Direction
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.DiscardPile
|
|
||||||
import eventDemo.shared.game.DrawPile
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.PlayerHand
|
|
||||||
import eventDemo.shared.game.PlayerList
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import eventDemo.testHelpers.act
|
|
||||||
import eventDemo.testHelpers.assert
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.datatest.withData
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import org.junit.jupiter.api.assertInstanceOf
|
|
||||||
|
|
||||||
class GameStartedTest :
|
|
||||||
FunSpec({
|
|
||||||
context(GameStarted::canBePlayThisCard.name) {
|
|
||||||
val dataOk: Map<String, Triple<Card, Card, Card.Color?>> =
|
|
||||||
listOf(
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.NumericCard(5, Card.Color.Red),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.NumericCard(0, Card.Color.Blue),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Green),
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.Plus2Card(Card.Color.Red),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.Plus2Card(Card.Color.Red),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.Plus4Card(),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus4Card(),
|
|
||||||
Card.Plus4Card(),
|
|
||||||
Card.Color.Red,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus4Card(),
|
|
||||||
Card.NumericCard(0, Card.Color.Blue),
|
|
||||||
Card.Color.Blue,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus2Card(Card.Color.Red),
|
|
||||||
Card.Plus2Card(Card.Color.Blue),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus2Card(Card.Color.Red),
|
|
||||||
Card.Plus4Card(),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus2Card(Card.Color.Red),
|
|
||||||
Card.ChangeColorCard(),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus2Card(Card.Color.Red),
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.ChangeColorCard(),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.ReverseCard(Card.Color.Red),
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
).associateBy { "I can play ${it.second} on ${it.first}${if (it.third != null) " when choose color is ${it.third}" else ""}" }
|
|
||||||
|
|
||||||
withData(dataOk) {
|
|
||||||
canBePlayThisCard(
|
|
||||||
it.first,
|
|
||||||
it.second,
|
|
||||||
it.third,
|
|
||||||
) shouldBe true
|
|
||||||
}
|
|
||||||
|
|
||||||
val dataKo: Map<String, Triple<Card, Card, Card.Color?>> =
|
|
||||||
listOf(
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.NumericCard(9, Card.Color.Blue),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.Plus2Card(Card.Color.Blue),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.NumericCard(0, Card.Color.Red),
|
|
||||||
Card.Plus2Card(Card.Color.Blue),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
Triple(
|
|
||||||
Card.Plus4Card(),
|
|
||||||
Card.NumericCard(0, Card.Color.Blue),
|
|
||||||
Card.Color.Red,
|
|
||||||
),
|
|
||||||
).associateBy { "I cannot play ${it.second} on ${it.first}${if (it.third != null) " when choose color is ${it.third}" else ""}" }
|
|
||||||
|
|
||||||
withData(dataKo) {
|
|
||||||
canBePlayThisCard(
|
|
||||||
it.first,
|
|
||||||
it.second,
|
|
||||||
it.third,
|
|
||||||
) shouldBe false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context("applyEvent") {
|
|
||||||
context("${CardIsPlayedEvent::class.simpleName}") {
|
|
||||||
test("with numeric card") {
|
|
||||||
val card1 = Card.NumericCard(2, Card.Color.Red)
|
|
||||||
assert {
|
|
||||||
gameWithCard(
|
|
||||||
played1Hand = PlayerHand(cards = setOf(card1)),
|
|
||||||
onTheDiscardPile = Card.NumericCard(0, Card.Color.Red),
|
|
||||||
).apply { nextPlayer shouldBe player1 }
|
|
||||||
}.act {
|
|
||||||
it.applyEvent(
|
|
||||||
CardIsPlayedEvent(
|
|
||||||
it.aggregateId,
|
|
||||||
card1,
|
|
||||||
playerId = it.player1.id,
|
|
||||||
version = it.version + 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.assert {
|
|
||||||
it.currentColor shouldBe Card.Color.Red
|
|
||||||
assertInstanceOf<Card.NumericCard>(it.lastPlayedCard).number shouldBe 2
|
|
||||||
it.direction shouldBe Game.Direction.CLOCKWISE
|
|
||||||
it.nextPlayer shouldBe it.player2
|
|
||||||
it.player1.hand.size shouldBe 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test("with revert turn card") {
|
|
||||||
val card1 = Card.ReverseCard(Card.Color.Red)
|
|
||||||
assert {
|
|
||||||
gameWithCard(
|
|
||||||
played1Hand = PlayerHand(cards = setOf(card1)),
|
|
||||||
onTheDiscardPile = Card.NumericCard(0, Card.Color.Red),
|
|
||||||
).apply { nextPlayer shouldBe player1 }
|
|
||||||
}.act {
|
|
||||||
it.applyEvent(
|
|
||||||
CardIsPlayedEvent(
|
|
||||||
it.aggregateId,
|
|
||||||
card1,
|
|
||||||
playerId = it.player1.id,
|
|
||||||
version = it.version + 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.assert {
|
|
||||||
it.currentColor shouldBe Card.Color.Red
|
|
||||||
assertInstanceOf<Card.ReverseCard>(it.lastPlayedCard).color shouldBe Card.Color.Red
|
|
||||||
it.direction shouldBe Game.Direction.COUNTER_CLOCKWISE
|
|
||||||
it.nextPlayer shouldBe it.player3
|
|
||||||
it.player1.hand.size shouldBe 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("${DrawFilledWithDiscardEvent::class.simpleName}") {
|
|
||||||
val card1 = Card.NumericCard(1, Card.Color.Blue)
|
|
||||||
val card2 = Card.NumericCard(2, Card.Color.Yellow)
|
|
||||||
val card3 = Card.NumericCard(3, Card.Color.Red)
|
|
||||||
assert {
|
|
||||||
val player1 = Player("Player 1", UserId())
|
|
||||||
val player2 = Player("Player 2", UserId())
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1)),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile =
|
|
||||||
DiscardPile(
|
|
||||||
setOf(
|
|
||||||
card1,
|
|
||||||
card2,
|
|
||||||
card3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
currentColor = card3.color,
|
|
||||||
version = 0,
|
|
||||||
recordedEvents = emptySet(),
|
|
||||||
)
|
|
||||||
}.act {
|
|
||||||
it.applyEvent(
|
|
||||||
DrawFilledWithDiscardEvent(
|
|
||||||
it.aggregateId,
|
|
||||||
version = it.version + 1,
|
|
||||||
newDrawPile =
|
|
||||||
DrawPile(
|
|
||||||
setOf(
|
|
||||||
card1,
|
|
||||||
card2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
newDiscardPile =
|
|
||||||
DiscardPile(
|
|
||||||
setOf(
|
|
||||||
card3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.assert {
|
|
||||||
it.currentColor shouldBe Card.Color.Red
|
|
||||||
assertInstanceOf<Card.NumericCard>(it.lastPlayedCard).color shouldBe Card.Color.Red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("${PlayerHaveDrawCardEvent::class.simpleName}") {
|
|
||||||
val card1 = Card.NumericCard(1, Card.Color.Blue)
|
|
||||||
val card2 = Card.NumericCard(2, Card.Color.Red)
|
|
||||||
val card3 = Card.NumericCard(3, Card.Color.Red)
|
|
||||||
val card4 = Card.NumericCard(4, Card.Color.Red)
|
|
||||||
|
|
||||||
val player1 = Player("Jo", UserId())
|
|
||||||
val player2 = Player("Bob", UserId())
|
|
||||||
assert {
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1)),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
drawPile =
|
|
||||||
DrawPile(
|
|
||||||
setOf(
|
|
||||||
card2,
|
|
||||||
card3,
|
|
||||||
card4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
discardPile =
|
|
||||||
DiscardPile(
|
|
||||||
setOf(
|
|
||||||
card1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
currentColor = card1.color,
|
|
||||||
version = 0,
|
|
||||||
recordedEvents = emptySet(),
|
|
||||||
)
|
|
||||||
}.act {
|
|
||||||
it.applyEvent(
|
|
||||||
PlayerHaveDrawCardEvent(
|
|
||||||
it.aggregateId,
|
|
||||||
version = it.version + 1,
|
|
||||||
playerId = it.player1.id,
|
|
||||||
takenCards =
|
|
||||||
setOf(
|
|
||||||
card2,
|
|
||||||
card3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.assert {
|
|
||||||
it.player1.hand
|
|
||||||
.run {
|
|
||||||
cards.elementAt(0) shouldBe card2
|
|
||||||
cards.elementAt(1) shouldBe card3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("${PlayerWinEvent::class.simpleName}") {
|
|
||||||
val player1 = Player("Jo", UserId())
|
|
||||||
val player2 = Player("Poo", UserId())
|
|
||||||
assert {
|
|
||||||
GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = PlayerList(setOf(player1, player2)),
|
|
||||||
lastPlayerId = player1.id,
|
|
||||||
nextPlayerId = player2.id,
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
discardPile = DiscardPile(),
|
|
||||||
currentColor = Card.Color.Yellow,
|
|
||||||
version = 0,
|
|
||||||
recordedEvents = emptySet(),
|
|
||||||
)
|
|
||||||
}.act {
|
|
||||||
it.applyEvent(
|
|
||||||
PlayerWinEvent(
|
|
||||||
it.aggregateId,
|
|
||||||
version = it.version + 1,
|
|
||||||
playerId = it.player1.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.assert {
|
|
||||||
it.playerWins.size shouldBe 1
|
|
||||||
it.playersInGame.size shouldBe 0
|
|
||||||
it.players.size shouldBe 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("nextPlayer") { }
|
|
||||||
|
|
||||||
test("players") { }
|
|
||||||
|
|
||||||
test("currentColor") { }
|
|
||||||
|
|
||||||
test("playedTurnHistory") { }
|
|
||||||
|
|
||||||
test("direction") { }
|
|
||||||
|
|
||||||
test("playerWins") { }
|
|
||||||
|
|
||||||
test("version") { }
|
|
||||||
|
|
||||||
test("recordedEvents") { }
|
|
||||||
})
|
|
||||||
|
|
||||||
private val GameStarted.player1: Player
|
|
||||||
get() = players.players.elementAt(0)
|
|
||||||
|
|
||||||
private val GameStarted.player2: Player
|
|
||||||
get() = players.players.elementAt(1)
|
|
||||||
|
|
||||||
private val GameStarted.player3: Player
|
|
||||||
get() = players.players.elementAt(2)
|
|
||||||
|
|
||||||
private fun canBePlayThisCard(
|
|
||||||
onTheDiscardPile: Card,
|
|
||||||
playedCard: Card,
|
|
||||||
chosenColor: Card.Color? = null,
|
|
||||||
): Boolean =
|
|
||||||
gameWithCard(
|
|
||||||
played1Hand = PlayerHand(setOf(playedCard)),
|
|
||||||
onTheDiscardPile = onTheDiscardPile,
|
|
||||||
chosenColor = chosenColor,
|
|
||||||
).run {
|
|
||||||
canBePlayThisCard(playedCard)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun gameWithCard(
|
|
||||||
played1Hand: PlayerHand,
|
|
||||||
played2Hand: PlayerHand = PlayerHand(setOf(Card.NumericCard(9, Card.Color.Yellow))),
|
|
||||||
onTheDiscardPile: Card,
|
|
||||||
chosenColor: Card.Color? = null,
|
|
||||||
): GameStarted {
|
|
||||||
val player1 = Player("Tesla", UserId(), hand = played1Hand)
|
|
||||||
val player2 = Player("Einstein", UserId(), hand = played2Hand)
|
|
||||||
val player3 = Player("Curie", UserId(), hand = PlayerHand(setOf(Card.NumericCard(8, Card.Color.Yellow))))
|
|
||||||
val players = PlayerList(setOf(player1, player2, player3))
|
|
||||||
return GameStarted(
|
|
||||||
aggregateId = GameId(),
|
|
||||||
players = players,
|
|
||||||
lastPlayerId = player3.id,
|
|
||||||
nextPlayerId = players.nextPlayerTurn(player3.id, Direction.CLOCKWISE),
|
|
||||||
discardPile = DiscardPile(setOf(onTheDiscardPile)),
|
|
||||||
drawPile = DrawPile(),
|
|
||||||
currentColor = (onTheDiscardPile as? Card.CardWithColor)?.color ?: chosenColor ?: error("no color"),
|
|
||||||
version = 0,
|
|
||||||
recordedEvents = emptySet(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package eventDemo.contexts.game.domain.game.gameState
|
|
||||||
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import io.kotest.assertions.retry
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.collections.shouldHaveSize
|
|
||||||
import io.kotest.matchers.should
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import io.kotest.matchers.shouldNotBe
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class NewDeckTest :
|
|
||||||
FunSpec({
|
|
||||||
|
|
||||||
test("newDeck") {
|
|
||||||
newDeck().let {
|
|
||||||
it shouldNotBe null
|
|
||||||
it.filterIsInstance<Card.NumericCard>() shouldHaveSize 76
|
|
||||||
it.filterIsInstance<Card.Plus2Card>() shouldHaveSize 8
|
|
||||||
it.filterIsInstance<Card.ReverseCard>() shouldHaveSize 8
|
|
||||||
it.filterIsInstance<Card.PassCard>() shouldHaveSize 8
|
|
||||||
it.filterIsInstance<Card.Plus4Card>() shouldHaveSize 4
|
|
||||||
it.filterIsInstance<Card.ChangeColorCard>() shouldHaveSize 4
|
|
||||||
it shouldHaveSize 108
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("shuffleDeck") {
|
|
||||||
val deck = (0..9).map { Card.NumericCard(it, Card.Color.Red) }
|
|
||||||
deck.run {
|
|
||||||
this[3].number shouldBe 3
|
|
||||||
}
|
|
||||||
should {
|
|
||||||
retry(maxRetry = 4, timeout = 1.seconds) {
|
|
||||||
deck.shuffled().run {
|
|
||||||
this[3].number shouldNotBe 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package eventDemo.contexts.game.intrastructure
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.contexts.auth.infrastructure.configure.makeJwt
|
|
||||||
import io.ktor.client.request.HttpRequestBuilder
|
|
||||||
import io.ktor.client.request.header
|
|
||||||
|
|
||||||
internal fun HttpRequestBuilder.withAuth(user: User) {
|
|
||||||
header("Authorization", "Bearer ${user.makeJwt("secret")}")
|
|
||||||
}
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
package eventDemo.contexts.game.intrastructure.persistence.connectors
|
|
||||||
|
|
||||||
import com.rabbitmq.client.AMQP.BasicProperties
|
|
||||||
import com.rabbitmq.client.BuiltinExchangeType
|
|
||||||
import com.rabbitmq.client.ConnectionFactory
|
|
||||||
import com.rabbitmq.client.DefaultConsumer
|
|
||||||
import com.rabbitmq.client.Envelope
|
|
||||||
import eventDemo.Tag
|
|
||||||
import eventDemo.testHelpers.spyPing
|
|
||||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.string.shouldStartWith
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class RabbitMQTest :
|
|
||||||
FunSpec({
|
|
||||||
tags(Tag.RabbitMQ)
|
|
||||||
|
|
||||||
test("test connection with RabbitMQ") {
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
val exchangeName = "test_" + UUID.randomUUID()
|
|
||||||
get<ConnectionFactory>().newConnection().use { connection ->
|
|
||||||
connection
|
|
||||||
.createChannel()
|
|
||||||
.use { channel ->
|
|
||||||
channel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT)
|
|
||||||
val queue = channel.queueDeclare("myQueue", true, false, false, emptyMap()).queue
|
|
||||||
channel.queueBind(queue, exchangeName, "")
|
|
||||||
|
|
||||||
spyPing(3.seconds, exactly = 2) { ping ->
|
|
||||||
channel
|
|
||||||
.basicConsume(
|
|
||||||
queue,
|
|
||||||
object : DefaultConsumer(channel) {
|
|
||||||
override fun handleDelivery(
|
|
||||||
consumerTag: String,
|
|
||||||
envelope: Envelope,
|
|
||||||
properties: BasicProperties,
|
|
||||||
body: ByteArray,
|
|
||||||
) {
|
|
||||||
val msg = body.toString(Charsets.UTF_8)
|
|
||||||
msg shouldStartWith "testMessage"
|
|
||||||
ping()
|
|
||||||
channel.basicAck(envelope.deliveryTag, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage1".toByteArray())
|
|
||||||
channel.basicPublish(exchangeName, "", BasicProperties(), "testMessage2".toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.queueDelete(queue)
|
|
||||||
channel.exchangeDelete(exchangeName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
package eventDemo.contexts.game.intrastructure.persistence.eventBus
|
|
||||||
|
|
||||||
import com.rabbitmq.client.ConnectionFactory
|
|
||||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
|
||||||
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInMemory
|
|
||||||
import eventDemo.contexts.game.infrastructure.persistence.eventBus.GameEventBusInRabbinMQ
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import eventDemo.shared.ids.UserId
|
|
||||||
import eventDemo.testHelpers.spyPing
|
|
||||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.datatest.withData
|
|
||||||
import io.kotest.matchers.equals.shouldBeEqual
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class GameEventBusInRabbitMQTest :
|
|
||||||
FunSpec({
|
|
||||||
context("Pub/sub") {
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
val busListToTest: Map<String, GameEventBus> =
|
|
||||||
mapOf(
|
|
||||||
GameEventBusInMemory::class.java.simpleName to GameEventBusInMemory(),
|
|
||||||
GameEventBusInRabbinMQ::class.java.simpleName to GameEventBusInRabbinMQ(get<ConnectionFactory>()),
|
|
||||||
)
|
|
||||||
|
|
||||||
withData(busListToTest) { bus ->
|
|
||||||
spyPing(1.seconds, exactly = 2) { ping ->
|
|
||||||
val aggregateId = GameId()
|
|
||||||
val player1 = Player(name = "Tesla", UserId())
|
|
||||||
val player2 = Player(name = "Einstein", UserId())
|
|
||||||
|
|
||||||
bus.subscribe { obj ->
|
|
||||||
ping()
|
|
||||||
obj.aggregateId shouldBeEqual aggregateId
|
|
||||||
}
|
|
||||||
bus.publish(NewPlayerEvent(aggregateId, player1, 1))
|
|
||||||
bus.publish(NewPlayerEvent(aggregateId, player2, 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
-128
@@ -1,128 +0,0 @@
|
|||||||
package eventDemo.contexts.game.intrastructure.rest
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
|
||||||
import eventDemo.contexts.game.intrastructure.httpClient
|
|
||||||
import eventDemo.contexts.game.intrastructure.withAuth
|
|
||||||
import eventDemo.shared.game.projection.GameList
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsHelpers
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsHelpers.joinTheGame
|
|
||||||
import eventDemo.testHelpers.CreateGameWithCommandsHelpers.readyToPlay
|
|
||||||
import eventDemo.testHelpers.createNewUser
|
|
||||||
import eventDemo.testHelpers.testApplicationWithConfig
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import io.kotest.assertions.nondeterministic.eventually
|
|
||||||
import io.kotest.assertions.nondeterministic.eventuallyConfig
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.collections.shouldContain
|
|
||||||
import io.kotest.matchers.collections.shouldHaveSize
|
|
||||||
import io.kotest.matchers.equals.shouldBeEqual
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.request.accept
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.statement.bodyAsText
|
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
class GameListRouteTest :
|
|
||||||
FunSpec({
|
|
||||||
test("/games with no game started") {
|
|
||||||
val user1 = createNewUser("user1")
|
|
||||||
testApplicationWithConfig({
|
|
||||||
get<UserRepository>().save(user1)
|
|
||||||
}) {
|
|
||||||
logger.info { "Starting player1" }
|
|
||||||
httpClient()
|
|
||||||
.get("/games") {
|
|
||||||
withAuth(user1)
|
|
||||||
accept(ContentType.Application.Json)
|
|
||||||
}.apply {
|
|
||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
|
||||||
val list = call.body<List<GameList>>()
|
|
||||||
assertTrue(list.isEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("/games return a game with status OPENING") {
|
|
||||||
val user1 = createNewUser("user1")
|
|
||||||
testApplicationWithConfig({
|
|
||||||
get<UserRepository>().save(user1)
|
|
||||||
CreateGameWithCommandsHelpers.createGameWithCommands {
|
|
||||||
user1.joinTheGame()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
// Wait until the projection is created
|
|
||||||
eventually(
|
|
||||||
eventuallyConfig {
|
|
||||||
initialDelay = 1.seconds
|
|
||||||
interval = 1.seconds
|
|
||||||
duration = 3.seconds
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
httpClient()
|
|
||||||
.get("/games") {
|
|
||||||
withAuth(user1)
|
|
||||||
accept(ContentType.Application.Json)
|
|
||||||
}.apply {
|
|
||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
|
||||||
call.body<List<GameList>>().let {
|
|
||||||
assertNotNull(it)
|
|
||||||
assertTrue { it.isNotEmpty() }
|
|
||||||
it.firstOrNull()?.run {
|
|
||||||
status shouldBeEqual GameList.Status.OPENING
|
|
||||||
players shouldHaveSize 1
|
|
||||||
players.map { it.userId } shouldContain user1.id
|
|
||||||
winners shouldHaveSize 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("/games return a game with status IS_STARTED") {
|
|
||||||
val user1 = createNewUser("user1")
|
|
||||||
val user2 = createNewUser("user2")
|
|
||||||
testApplicationWithConfig({
|
|
||||||
CreateGameWithCommandsHelpers.createGameWithCommands {
|
|
||||||
get<UserRepository>().run {
|
|
||||||
save(user1)
|
|
||||||
save(user2)
|
|
||||||
}
|
|
||||||
user1.joinTheGame()
|
|
||||||
user2.joinTheGame()
|
|
||||||
getPlayer(user1).readyToPlay()
|
|
||||||
getPlayer(user2).readyToPlay()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
eventually(
|
|
||||||
eventuallyConfig {
|
|
||||||
initialDelay = 1.seconds
|
|
||||||
interval = 1.seconds
|
|
||||||
duration = 3.seconds
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
httpClient()
|
|
||||||
.get("/games") {
|
|
||||||
withAuth(user1)
|
|
||||||
accept(ContentType.Application.Json)
|
|
||||||
}.apply {
|
|
||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
|
||||||
call.body<List<GameList>>().first().let {
|
|
||||||
it.status shouldBeEqual GameList.Status.IS_STARTED
|
|
||||||
it.players shouldHaveSize 2
|
|
||||||
it.players.map { it.userId } shouldContain user1.id
|
|
||||||
it.players.map { it.userId } shouldContain user2.id
|
|
||||||
it.winners shouldHaveSize 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package eventDemo.libs.bus
|
|
||||||
|
|
||||||
import com.rabbitmq.client.ConnectionFactory
|
|
||||||
import eventDemo.testHelpers.spyPing
|
|
||||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.datatest.withData
|
|
||||||
import io.kotest.matchers.string.shouldStartWith
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
private data class ObjTest(
|
|
||||||
val value: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
class BusTest :
|
|
||||||
FunSpec({
|
|
||||||
context("Pub/sub") {
|
|
||||||
testKoinApplicationWithConfig {
|
|
||||||
val list: Map<String, Bus<ObjTest>> =
|
|
||||||
mapOf(
|
|
||||||
BusInMemory::class.java.simpleName to BusInMemory(),
|
|
||||||
BusInRabbitMQ::class.java.simpleName to
|
|
||||||
BusInRabbitMQ(
|
|
||||||
get<ConnectionFactory>(),
|
|
||||||
"testExchange",
|
|
||||||
{ it.value },
|
|
||||||
{ ObjTest(it) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
withData(list) { bus ->
|
|
||||||
spyPing(exactly = 2, duration = 1.seconds) { ping ->
|
|
||||||
bus.subscribe { obj ->
|
|
||||||
ping()
|
|
||||||
obj.value shouldStartWith "testMessage"
|
|
||||||
}
|
|
||||||
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
|
|
||||||
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package eventDemo.libs.command
|
|
||||||
|
|
||||||
import eventDemo.shared.command.Command
|
|
||||||
import eventDemo.shared.ids.CommandId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CommandForTest(
|
|
||||||
override val id: CommandId,
|
|
||||||
) : Command
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package eventDemo.libs.command
|
|
||||||
|
|
||||||
import eventDemo.shared.ids.CommandId
|
|
||||||
import eventDemo.testHelpers.spyPing
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import org.junit.jupiter.api.assertThrows
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class CommandUnicityCheckerTest :
|
|
||||||
FunSpec({
|
|
||||||
test("runOnlyOnce must run all commands") {
|
|
||||||
spyPing(exactly = 2, duration = 3.seconds) { ping ->
|
|
||||||
val com1 = CommandForTest(CommandId())
|
|
||||||
val com2 = CommandForTest(CommandId())
|
|
||||||
CommandUnicityChecker<CommandForTest>().run {
|
|
||||||
runOnlyOnce(com1) { ping() }
|
|
||||||
runOnlyOnce(com2) { ping() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("runOnlyOnce") {
|
|
||||||
spyPing(exactly = 2, duration = 3.seconds) { ping ->
|
|
||||||
val com1 = CommandForTest(CommandId())
|
|
||||||
val com2 = CommandForTest(CommandId())
|
|
||||||
CommandUnicityChecker<CommandForTest>().run {
|
|
||||||
runOnlyOnce(com1) { ping() }
|
|
||||||
runOnlyOnce(com2) { ping() }
|
|
||||||
assertThrows<CommandUnicityChecker.UnicityException> {
|
|
||||||
runOnlyOnce(com2) { ping() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package eventDemo.testHelpers
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.command.JoinTheGameCommand
|
|
||||||
import eventDemo.shared.game.command.PlayCardCommand
|
|
||||||
import eventDemo.shared.game.command.ReadyToPlayCommand
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import org.koin.core.Koin
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.uuid.toKotlinUuid
|
|
||||||
|
|
||||||
object CreateGameWithCommandsHelpers {
|
|
||||||
class Data(
|
|
||||||
private val repo: GameRepository,
|
|
||||||
val gameId: GameId,
|
|
||||||
) {
|
|
||||||
fun getPlayer(user: User): Player =
|
|
||||||
repo.get(gameId)!!.players.get(user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
context(koin: Koin)
|
|
||||||
fun <T> createGameWithCommands(
|
|
||||||
gameName: String = "testGame${UUID.randomUUID()}",
|
|
||||||
block: context(CreateGameWithCommandsHelpers, GameCommandHandlerDispatcher) Data.() -> T,
|
|
||||||
): T {
|
|
||||||
val gameId = GameId(UUID.nameUUIDFromBytes(gameName.encodeToByteArray()).toKotlinUuid())
|
|
||||||
val repo = koin.get<GameRepository>()
|
|
||||||
repo.create(gameId)
|
|
||||||
|
|
||||||
return koin.get<GameCommandHandlerDispatcher>().run {
|
|
||||||
with(CreateGameWithCommandsHelpers) {
|
|
||||||
Data(repo, gameId).block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context(dispatcher: GameCommandHandlerDispatcher, data: Data)
|
|
||||||
fun User.joinTheGame(): JoinTheGameCommand =
|
|
||||||
JoinTheGameCommand(
|
|
||||||
id,
|
|
||||||
JoinTheGameCommand.Payload(data.gameId),
|
|
||||||
).also { dispatcher.dispatch(it) }
|
|
||||||
|
|
||||||
context(dispatcher: GameCommandHandlerDispatcher, data: Data)
|
|
||||||
fun Player.readyToPlay(): ReadyToPlayCommand =
|
|
||||||
ReadyToPlayCommand(
|
|
||||||
userId,
|
|
||||||
ReadyToPlayCommand.Payload(data.gameId, id),
|
|
||||||
).also { dispatcher.dispatch(it) }
|
|
||||||
|
|
||||||
context(dispatcher: GameCommandHandlerDispatcher, data: Data)
|
|
||||||
fun Player.playCard(
|
|
||||||
card: Card,
|
|
||||||
chosenColor: Card.Color? = null,
|
|
||||||
): PlayCardCommand =
|
|
||||||
PlayCardCommand(
|
|
||||||
userId,
|
|
||||||
PlayCardCommand.Payload(data.gameId, id, card, chosenColor),
|
|
||||||
).also { dispatcher.dispatch(it) }
|
|
||||||
}
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
package eventDemo.testHelpers
|
|
||||||
|
|
||||||
import eventDemo.contexts.auth.domain.User
|
|
||||||
import eventDemo.contexts.game.application.eventStores.GameRepository
|
|
||||||
import eventDemo.contexts.game.domain.game.gameState.Game
|
|
||||||
import eventDemo.shared.game.Card
|
|
||||||
import eventDemo.shared.game.Player
|
|
||||||
import eventDemo.shared.game.command.GameCommand
|
|
||||||
import eventDemo.shared.game.command.JoinTheGameCommand
|
|
||||||
import eventDemo.shared.game.command.PlayCardCommand
|
|
||||||
import eventDemo.shared.game.command.ReadyToPlayCommand
|
|
||||||
import eventDemo.shared.ids.GameId
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import org.koin.core.Koin
|
|
||||||
|
|
||||||
object CreateGameWithCommandsInChannelsHelpers {
|
|
||||||
class Data(
|
|
||||||
private val repo: GameRepository,
|
|
||||||
val gameId: GameId,
|
|
||||||
val currentUser: User,
|
|
||||||
) {
|
|
||||||
fun getPlayer(user: User): Player =
|
|
||||||
game.players.get(user.id)
|
|
||||||
|
|
||||||
val currentPlayer: Player get() = getPlayer(currentUser)
|
|
||||||
|
|
||||||
val game: Game
|
|
||||||
get() = repo.get(gameId)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
context(koin: Koin)
|
|
||||||
suspend fun <T> createGameWithCommandsInChannels(
|
|
||||||
channelCommand: Channel<GameCommand>,
|
|
||||||
gameId: GameId,
|
|
||||||
user: User,
|
|
||||||
block:
|
|
||||||
suspend context(
|
|
||||||
CreateGameWithCommandsInChannelsHelpers,
|
|
||||||
Channel<GameCommand>,
|
|
||||||
User,
|
|
||||||
) Data.() -> T,
|
|
||||||
): T {
|
|
||||||
val repo = koin.get<GameRepository>()
|
|
||||||
repo.getOrCreate(gameId)
|
|
||||||
|
|
||||||
return with(channelCommand) {
|
|
||||||
with(user) {
|
|
||||||
with(CreateGameWithCommandsInChannelsHelpers) {
|
|
||||||
Data(repo, gameId, user).block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context(channelCommand: Channel<GameCommand>, data: Data)
|
|
||||||
suspend fun joinTheGame(): JoinTheGameCommand =
|
|
||||||
JoinTheGameCommand(
|
|
||||||
data.currentUser.id,
|
|
||||||
JoinTheGameCommand.Payload(data.gameId),
|
|
||||||
).also { channelCommand.send(it) }
|
|
||||||
|
|
||||||
context(channelCommand: Channel<GameCommand>, data: Data)
|
|
||||||
suspend fun readyToPlay(): ReadyToPlayCommand =
|
|
||||||
ReadyToPlayCommand(
|
|
||||||
data.currentUser.id,
|
|
||||||
ReadyToPlayCommand.Payload(data.gameId, data.currentPlayer.id),
|
|
||||||
).also { channelCommand.send(it) }
|
|
||||||
|
|
||||||
context(channelCommand: Channel<GameCommand>, data: Data)
|
|
||||||
suspend fun playCard(
|
|
||||||
card: Card,
|
|
||||||
chosenColor: Card.Color? = null,
|
|
||||||
): PlayCardCommand =
|
|
||||||
PlayCardCommand(
|
|
||||||
data.currentUser.id,
|
|
||||||
PlayCardCommand.Payload(data.gameId, data.currentPlayer.id, card, chosenColor),
|
|
||||||
).also { channelCommand.send(it) }
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user