Compare commits
1
Commits
v0.2
..
256524f63e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256524f63e
|
+14
-14
@@ -18,10 +18,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -48,16 +48,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Restore Gradle cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: ./gradlew ktlintCheck
|
||||
|
||||
- name: Publish ktlint report
|
||||
uses: yutailang0119/action-ktlint@v5
|
||||
uses: yutailang0119/action-ktlint@v4
|
||||
if: always()
|
||||
with:
|
||||
report-path: build/reports/ktlint/**/*.xml
|
||||
@@ -84,16 +84,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Restore Gradle cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -106,20 +106,20 @@ jobs:
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Start CI Docker Compose services
|
||||
run: ./gradlew ciComposeUp -Pci
|
||||
run: ./gradlew composeUp -Pci
|
||||
|
||||
- name: Run tests
|
||||
run: ./gradlew test -x ciComposeUp -Pci --no-daemon
|
||||
run: ./gradlew test -x composeUp --no-daemon
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: build/reports/tests/test
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: dorny/test-reporter@v3
|
||||
uses: dorny/test-reporter@v1
|
||||
if: always()
|
||||
with:
|
||||
name: JUnit Tests
|
||||
|
||||
-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>
|
||||
+51
-64
@@ -1,20 +1,21 @@
|
||||
import com.avast.gradle.dockercompose.ComposeExtension
|
||||
@file:Suppress("PropertyName")
|
||||
|
||||
import org.jlleitschuh.gradle.ktlint.KtlintExtension
|
||||
|
||||
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
|
||||
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
|
||||
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
|
||||
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
|
||||
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
|
||||
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
|
||||
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
|
||||
val ktor_version: String by project
|
||||
val kotlin_version: String by project
|
||||
val kotlin_serialization_version: String by project
|
||||
val logback_version: String by project
|
||||
val koin_version: String by project
|
||||
val kotlin_logging_version: String by project
|
||||
val kotest_version: String by project
|
||||
|
||||
plugins {
|
||||
application
|
||||
kotlin("jvm") version "2.1.21"
|
||||
id("io.ktor.plugin") version "3.5.1"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
|
||||
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
|
||||
kotlin("jvm") version "2.1.10"
|
||||
id("io.ktor.plugin") version "3.1.1"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
|
||||
id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
|
||||
id("com.avast.gradle.docker-compose") version "0.17.12"
|
||||
}
|
||||
|
||||
@@ -28,7 +29,7 @@ application {
|
||||
}
|
||||
|
||||
configure<KtlintExtension> {
|
||||
version.set("1.8.0")
|
||||
version.set("1.5.0")
|
||||
}
|
||||
ktlint {
|
||||
reporters {
|
||||
@@ -50,42 +51,29 @@ tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
configure<ComposeExtension> {
|
||||
createNested("test").apply {
|
||||
useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
||||
setProjectName("event-demo-test")
|
||||
}
|
||||
createNested("dev").apply {
|
||||
useComposeFiles.set(listOf("docker/docker-compose-dev.yaml"))
|
||||
setProjectName("event-demo-dev")
|
||||
}
|
||||
createNested("ci").apply {
|
||||
useComposeFiles.set(listOf("docker/docker-compose-ci.yaml"))
|
||||
setProjectName("event-demo-ci")
|
||||
}
|
||||
dockerCompose {
|
||||
val composeFile =
|
||||
if (project.hasProperty("ci")) {
|
||||
// Use docker-compose-ci.yaml for the CI
|
||||
"docker/docker-compose-ci.yaml"
|
||||
} else {
|
||||
// Use docker-compose-test.yaml for local tests
|
||||
"docker/docker-compose-test.yaml"
|
||||
}
|
||||
useComposeFiles.set(listOf(composeFile))
|
||||
setProjectName("event-demo-test")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
if (project.hasProperty("ci")) {
|
||||
dependsOn("ciComposeUp")
|
||||
} else {
|
||||
dependsOn("testComposeUp")
|
||||
}
|
||||
dependsOn("composeUp")
|
||||
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
||||
dockerCompose.setProjectName("event-demo-test")
|
||||
}
|
||||
|
||||
tasks.named("run") {
|
||||
dependsOn("devComposeUp")
|
||||
}
|
||||
tasks.composeUp {
|
||||
dependsOn("copyEnv")
|
||||
}
|
||||
tasks.named("devComposeUp") {
|
||||
dependsOn("copyEnv")
|
||||
}
|
||||
tasks.named("testComposeUp") {
|
||||
dependsOn("copyEnv")
|
||||
}
|
||||
tasks.named("ciComposeUp") {
|
||||
dependsOn("copyEnv")
|
||||
dependsOn("composeUp")
|
||||
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
||||
dockerCompose.setProjectName("event-demo-dev")
|
||||
}
|
||||
|
||||
tasks.register<Copy>("copyEnv") {
|
||||
@@ -102,15 +90,13 @@ tasks.register<Copy>("copyEnv") {
|
||||
exclude()
|
||||
}
|
||||
}
|
||||
val files =
|
||||
buildList {
|
||||
add(File("docker/postgresql.secret"))
|
||||
if (!project.hasProperty("ci")) {
|
||||
add(File("docker/pgadmin.secret"))
|
||||
}
|
||||
}
|
||||
outputs.files(*files.toTypedArray())
|
||||
doLast {
|
||||
val files =
|
||||
listOf(
|
||||
File("docker/pgadmin.secret"),
|
||||
File("docker/postgresql.secret"),
|
||||
)
|
||||
|
||||
files.forEach {
|
||||
if (!it.exists()) {
|
||||
it.writeText("changeit")
|
||||
@@ -118,6 +104,9 @@ tasks.register<Copy>("copyEnv") {
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.composeUp {
|
||||
dependsOn("copyEnv")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-server-core-jvm")
|
||||
@@ -135,25 +124,23 @@ dependencies {
|
||||
implementation("io.ktor:ktor-server-data-conversion")
|
||||
implementation("io.ktor:ktor-client-content-negotiation")
|
||||
implementation("io.ktor:ktor-client-auth")
|
||||
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
|
||||
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
|
||||
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
|
||||
implementation("ch.qos.logback:logback-classic:$logback_version")
|
||||
implementation("io.insert-koin:koin-ktor:$koin_version")
|
||||
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
|
||||
implementation("redis.clients:jedis:5.2.0")
|
||||
implementation("org.postgresql:postgresql:42.7.13")
|
||||
implementation("org.postgresql:postgresql:42.7.5")
|
||||
implementation("com.zaxxer:HikariCP:6.3.0")
|
||||
implementation("com.rabbitmq:amqp-client:5.25.0")
|
||||
implementation("com.password4j:password4j:1.8.4")
|
||||
|
||||
// Force version of sub library (for security)
|
||||
implementation("commons-codec:commons-codec:1.13")
|
||||
|
||||
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
|
||||
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
|
||||
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
|
||||
testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
|
||||
testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version")
|
||||
testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
|
||||
testImplementation("io.mockk:mockk:1.13.17")
|
||||
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Exemple de structure
|
||||
|
||||
Les couches, du plus interne au plus externe
|
||||
```
|
||||
Domain (le cœur, ne dépend de RIEN d'externe)
|
||||
↑
|
||||
Application (orchestre le Domain, ne connaît pas l'infra concrète)
|
||||
↑
|
||||
Infrastructure (WebSocket, DB, event store — dépend de tout le reste)
|
||||
```
|
||||
|
||||
```
|
||||
src/
|
||||
└── contexts/
|
||||
├── auth/
|
||||
└── ...
|
||||
└── game/
|
||||
├── domain/ ← Le cœur métier, zéro dépendance externe
|
||||
│ ├── game/
|
||||
│ │ ├── Game.ts ← Aggregate Root
|
||||
│ │ ├── Player.ts ← Entity interne
|
||||
│ │ ├── Card.ts ← Entity
|
||||
│ │ ├── Color.ts ← Value Object
|
||||
│ │ ├── Deck.ts ← VO ou petite structure
|
||||
│ │ └── errors/
|
||||
│ │ ├── InvalidMoveError.ts
|
||||
│ │ └── ColorChoiceRequiredError.ts
|
||||
│ └── events/ ← Events de DOMAINE (internes)
|
||||
│ ├── CardPlayed.ts
|
||||
│ ├── CardDrawn.ts
|
||||
│ ├── TurnPassed.ts
|
||||
│ └── DomainEvent.ts ← interface/type de base
|
||||
├── application/ ← Orchestration, cas d'usage
|
||||
│ ├── commands/ ← Les Commandes (intentions)
|
||||
│ │ ├── PlayCardCommand.ts
|
||||
│ │ └── DrawCardCommand.ts
|
||||
│ ├── handlers/ ← Un handler par commande
|
||||
│ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save
|
||||
│ │ └── DrawCardHandler.ts
|
||||
│ ├── projections/ ← LA LOGIQUE de construction des projections
|
||||
│ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue
|
||||
│ │ └── PlayerStatsProjector.kt
|
||||
│ └── ports/ ← INTERFACES seulement (le "hexagone")
|
||||
│ ├── GameRepository.ts ← interface, pas d'implémentation
|
||||
│ ├── EventPublisher.ts ← interface, pas d'implémentation
|
||||
│ └── ProjectionStore.kt ← interface, où lire/écrire la projection
|
||||
├── infrastructure/ ← Tout ce qui est technique/externe
|
||||
│ ├── persistence/
|
||||
│ │ ├── EventStoreGameRepository.ts ← implémente GameRepository
|
||||
│ │ ├── EventStore.ts
|
||||
│ │ ├── projections/
|
||||
│ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée)
|
||||
│ │ │ └── models/
|
||||
│ │ │ └── GameSummaryView.kt ← structure de la vue elle-même
|
||||
│ ├── websocket/
|
||||
│ │ ├── WebSocketServer.ts
|
||||
│ │ ├── connectionManager.ts ← Map<gameId, Map<playerId, WebSocket>>
|
||||
│ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler
|
||||
│ └── eventPublisher/
|
||||
│ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast
|
||||
└── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer)
|
||||
├── clientEvents/
|
||||
│ ├── ClientEvent.ts ← types des events envoyés au front
|
||||
│ └── toClientEvent.ts ← fonction de traduction domain event → client event
|
||||
└── clientCommands/
|
||||
└── parseIncomingCommand.ts ← valide/parse le message brut du client → Command
|
||||
```
|
||||
@@ -1,5 +1,6 @@
|
||||
name: event-demo-ci
|
||||
name: event-demo-test
|
||||
include:
|
||||
- path:
|
||||
- parts/docker-compose-databases.yaml
|
||||
- parts/docker-compose-databases-expose.yaml
|
||||
- parts/docker-compose-databases-expose.yaml
|
||||
- parts/docker-compose-traefik.yaml
|
||||
@@ -1,4 +0,0 @@
|
||||
name: event-demo-test
|
||||
include:
|
||||
- path:
|
||||
- docker-compose-test.yaml
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis/redis-stack:7.4.0-v8
|
||||
image: redis/redis-stack:7.4.0-v3
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
||||
volumes:
|
||||
@@ -19,19 +19,20 @@ services:
|
||||
restart: no
|
||||
|
||||
postgresql:
|
||||
image: postgres:18.4
|
||||
image: postgres:17.4
|
||||
command: postgres -c 'max_connections=500'
|
||||
environment:
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
|
||||
POSTGRES_USER: event-demo
|
||||
secrets:
|
||||
- postgresql_password
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- postgresql_data:/var/lib/postgresql
|
||||
- ../postgresql.secret:/run/secrets/postgresql_password:ro
|
||||
- postgresql_data:/var/lib/postgresql/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4-management-alpine
|
||||
@@ -46,6 +47,10 @@ services:
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq/
|
||||
|
||||
secrets:
|
||||
postgresql_password:
|
||||
file: ../postgresql.secret
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
redisinsight_data:
|
||||
|
||||
@@ -4,20 +4,10 @@ services:
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
|
||||
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
|
||||
secrets:
|
||||
- pgadmin_password
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
configs:
|
||||
- source: pgpass
|
||||
target: /pgpass
|
||||
mode: 0600
|
||||
uid: "5050"
|
||||
gid: "5050"
|
||||
- source: servers_json
|
||||
target: /pgadmin4/servers.json
|
||||
labels:
|
||||
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
|
||||
- "traefik.http.services.pgadmin.loadbalancer.server.port=80"
|
||||
@@ -34,27 +24,6 @@ services:
|
||||
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
|
||||
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
|
||||
|
||||
configs:
|
||||
pgpass:
|
||||
content: |
|
||||
*:*:*:event-demo:changeit
|
||||
servers_json:
|
||||
content: |
|
||||
{
|
||||
"Servers": {
|
||||
"1": {
|
||||
"Name": "Event demo",
|
||||
"Group": "Servers",
|
||||
"Host": "postgresql",
|
||||
"Port": 5432,
|
||||
"MaintenanceDB": "event-demo",
|
||||
"Username": "event-demo",
|
||||
"PassFile": "/pgpass",
|
||||
"SSLMode": "prefer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
secrets:
|
||||
pgadmin_password:
|
||||
file: ../pgadmin.secret
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:3.7.9
|
||||
image: traefik:3.3.4
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--api.dashboard=true"
|
||||
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
ktor_version=3.5.1
|
||||
kotlin_version=2.1.21
|
||||
kotlin_serialization_version=1.11.0
|
||||
logback_version=1.5.38
|
||||
koin_version=4.2.1
|
||||
kotlin_logging_version=8.0.4
|
||||
kotest_version=6.2.2
|
||||
ktor_version=3.1.1
|
||||
kotlin_version=2.1.10
|
||||
kotlin_serialization_version=1.8.0
|
||||
logback_version=1.5.17
|
||||
koin_version=4.0.2
|
||||
kotlin_logging_version=7.0.5
|
||||
kotest_version=6.0.0.M2
|
||||
|
||||
kotlin.code.style=official
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
create schema game;
|
||||
create table game.game_event_stream (
|
||||
create table event_stream (
|
||||
id uuid not null primary key,
|
||||
aggregate_id uuid not null,
|
||||
version int not null,
|
||||
@@ -1,8 +0,0 @@
|
||||
create schema auth;
|
||||
create table auth.user_event_stream (
|
||||
id uuid not null primary key,
|
||||
aggregate_id uuid not null,
|
||||
version int not null,
|
||||
data jsonb not null,
|
||||
unique(aggregate_id, version)
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
create table auth.user (
|
||||
id uuid not null primary key,
|
||||
username text not null,
|
||||
unique(id),
|
||||
unique(username)
|
||||
);
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.infrastructure.persistence.eventBus
|
||||
package eventDemo.adapter.infrastructureLayer.event
|
||||
|
||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||
import eventDemo.contexts.game.domain.events.GameEvent
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.bus.Bus
|
||||
import eventDemo.libs.bus.BusInMemory
|
||||
import java.util.UUID
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package eventDemo.contexts.game.infrastructure.persistence.eventBus
|
||||
package eventDemo.adapter.infrastructureLayer.event
|
||||
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||
import eventDemo.contexts.game.domain.events.GameEvent
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.bus.Bus
|
||||
import eventDemo.libs.bus.BusInRabbitMQ
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -0,0 +1,14 @@
|
||||
package eventDemo.adapter.infrastructureLayer.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventStore
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.EventStore
|
||||
import eventDemo.libs.event.EventStoreInMemory
|
||||
|
||||
/**
|
||||
* A stream to publish and read the played card event.
|
||||
*/
|
||||
class GameEventStoreInMemory :
|
||||
GameEventStore,
|
||||
EventStore<GameEvent, GameId> by EventStoreInMemory()
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package eventDemo.adapter.infrastructureLayer.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventStore
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.EventStore
|
||||
import eventDemo.libs.event.EventStoreInPostgresql
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* A stream to publish and read the played card event.
|
||||
*/
|
||||
class GameEventStoreInPostgresql(
|
||||
dataSource: DataSource,
|
||||
) : GameEventStore,
|
||||
EventStore<GameEvent, GameId> by EventStoreInPostgresql(
|
||||
dataSource,
|
||||
{ Json.encodeToString(it) },
|
||||
{ Json.decodeFromString(it) },
|
||||
)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.projection.GameList
|
||||
import eventDemo.business.event.projection.GameListRepository
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.business.event.projection.apply
|
||||
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
|
||||
/**
|
||||
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
|
||||
*/
|
||||
class GameListRepositoryInMemory : GameListRepository {
|
||||
private val projectionsRepository =
|
||||
ProjectionRepositoryInMemory(
|
||||
applyToProjection = GameList::apply,
|
||||
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
|
||||
)
|
||||
|
||||
fun subscribeToBus(
|
||||
projectionBus: GameProjectionBus,
|
||||
eventBus: GameEventBus,
|
||||
) {
|
||||
// On new event was received, build projection and publish it to the projection bus
|
||||
eventBus.subscribe { event ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
projectionsRepository
|
||||
.applyAndSave(event)
|
||||
.also { projectionBus.publish(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last version of the [GameState] from the all eventStream.
|
||||
*
|
||||
* It fetches it from the local cache if possible, otherwise it builds it.
|
||||
*/
|
||||
override fun getList(): List<GameList> =
|
||||
projectionsRepository.getList()
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.projection.GameList
|
||||
import eventDemo.business.event.projection.GameListRepository
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.business.event.projection.apply
|
||||
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
|
||||
/**
|
||||
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
|
||||
*/
|
||||
class GameListRepositoryInRedis(
|
||||
jedis: UnifiedJedis,
|
||||
) : GameListRepository {
|
||||
private val projectionsRepository =
|
||||
ProjectionRepositoryInRedis(
|
||||
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
|
||||
projectionClass = GameList::class,
|
||||
projectionToJson = { Json.encodeToString(GameList.serializer(), it) },
|
||||
jsonToProjection = { Json.decodeFromString(GameList.serializer(), it) },
|
||||
applyToProjection = GameList::apply,
|
||||
jedis = jedis,
|
||||
)
|
||||
|
||||
fun subscribeToBus(
|
||||
projectionBus: GameProjectionBus,
|
||||
eventBus: GameEventBus,
|
||||
) {
|
||||
eventBus.subscribe { event ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
projectionsRepository
|
||||
.applyAndSave(event)
|
||||
.also { projectionBus.publish(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last version of the [GameState] from the all eventStream.
|
||||
*
|
||||
* It fetches it from the local cache if possible, otherwise it builds it.
|
||||
*/
|
||||
override fun getList(): List<GameList> =
|
||||
projectionsRepository.getList()
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.infrastructure.persistence.projections.bus
|
||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
||||
|
||||
import eventDemo.contexts.game.application.ports.GameProjectionBus
|
||||
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
|
||||
import eventDemo.business.event.projection.GameProjection
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.libs.bus.Bus
|
||||
import eventDemo.libs.bus.BusInMemory
|
||||
import java.util.UUID
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package eventDemo.contexts.game.infrastructure.persistence.projections.bus
|
||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
||||
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import eventDemo.contexts.game.application.ports.GameProjectionBus
|
||||
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection
|
||||
import eventDemo.business.event.projection.GameProjection
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.libs.bus.Bus
|
||||
import eventDemo.libs.bus.BusInRabbitMQ
|
||||
import kotlinx.serialization.json.Json
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
import eventDemo.business.event.projection.apply
|
||||
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
|
||||
/**
|
||||
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
|
||||
*/
|
||||
class GameStateRepositoryInMemory : GameStateRepository {
|
||||
private val projectionsRepository =
|
||||
ProjectionRepositoryInMemory(
|
||||
applyToProjection = GameState::apply,
|
||||
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
|
||||
)
|
||||
|
||||
fun subscribeToBus(
|
||||
projectionBus: GameProjectionBus,
|
||||
eventBus: GameEventBus,
|
||||
) {
|
||||
// On new event was received, build projection and publish it to the projection bus
|
||||
eventBus.subscribe { event ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
projectionsRepository
|
||||
.applyAndSave(event)
|
||||
.also { projectionBus.publish(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the [GameState].
|
||||
*/
|
||||
override fun get(gameId: GameId): GameState =
|
||||
projectionsRepository.get(gameId)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
import eventDemo.business.event.projection.apply
|
||||
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
|
||||
/**
|
||||
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
|
||||
*/
|
||||
class GameStateRepositoryInRedis(
|
||||
jedis: UnifiedJedis,
|
||||
) : GameStateRepository {
|
||||
private val projectionsRepository =
|
||||
ProjectionRepositoryInRedis(
|
||||
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
|
||||
projectionClass = GameState::class,
|
||||
projectionToJson = { Json.encodeToString(GameState.serializer(), it) },
|
||||
jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) },
|
||||
applyToProjection = GameState::apply,
|
||||
jedis = jedis,
|
||||
)
|
||||
|
||||
fun subscribeToBus(
|
||||
projectionBus: GameProjectionBus,
|
||||
eventBus: GameEventBus,
|
||||
) {
|
||||
// On new event was received, build projection and publish it to the projection bus
|
||||
eventBus.subscribe { event ->
|
||||
withLoggingContext("event" to event.toString()) {
|
||||
projectionsRepository
|
||||
.applyAndSave(event)
|
||||
.also { projectionBus.publish(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the [GameState].
|
||||
*/
|
||||
override fun get(gameId: GameId): GameState =
|
||||
projectionsRepository.get(gameId)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eventDemo.adapter.interfaceLayer.query
|
||||
|
||||
import eventDemo.business.command.GameCommandHandler
|
||||
import eventDemo.business.command.command.GameCommand
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
|
||||
import eventDemo.business.notification.Notification
|
||||
import eventDemo.libs.fromFrameChannel
|
||||
import eventDemo.libs.toObjectChannel
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
fun Route.gameWebSocket(
|
||||
playerNotificationListener: PlayerNotificationListener,
|
||||
commandHandler: GameCommandHandler,
|
||||
) {
|
||||
authenticate {
|
||||
webSocket("/games/new") {
|
||||
runWebSocket(GameId(), commandHandler, playerNotificationListener)
|
||||
}
|
||||
|
||||
webSocket("/games/{id}") {
|
||||
val gameId = GameId(UUID.fromString(call.parameters["id"]!!))
|
||||
runWebSocket(gameId, commandHandler, playerNotificationListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
private fun DefaultWebSocketServerSession.runWebSocket(
|
||||
gameId: GameId,
|
||||
commandHandler: GameCommandHandler,
|
||||
playerNotificationListener: PlayerNotificationListener,
|
||||
) {
|
||||
val currentPlayer = call.getPlayerCredentials()
|
||||
val incomingFrameChannel: ReceiveChannel<GameCommand> = toObjectChannel(incoming)
|
||||
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
|
||||
withLoggingContext("currentPlayer" to currentPlayer.toString()) {
|
||||
val notificationListener =
|
||||
playerNotificationListener.startListening(
|
||||
currentPlayer,
|
||||
gameId,
|
||||
) { outgoingFrameChannel.trySendBlocking(it) }
|
||||
|
||||
// TODO change GlobalScope
|
||||
GlobalScope.launch {
|
||||
commandHandler.handleIncomingPlayerCommands(
|
||||
currentPlayer,
|
||||
gameId,
|
||||
incomingFrameChannel,
|
||||
outgoingFrameChannel,
|
||||
)
|
||||
notificationListener.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
package eventDemo.contexts.game.infrastructure.rest
|
||||
package eventDemo.adapter.interfaceLayer.query
|
||||
|
||||
import eventDemo.domain.event.projection.GameListRepository
|
||||
import eventDemo.business.event.projection.GameListRepository
|
||||
import io.ktor.resources.Resource
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.resources.get
|
||||
@@ -15,7 +15,7 @@ class Games
|
||||
/**
|
||||
* API routes to show all games.
|
||||
*/
|
||||
fun Route.gamesListRoute(gameListRepository: GameListRepository) {
|
||||
fun Route.readGamesList(gameListRepository: GameListRepository) {
|
||||
authenticate {
|
||||
// Read the last played card on the game.
|
||||
get<Games> {
|
||||
@@ -0,0 +1,14 @@
|
||||
package eventDemo.adapter.interfaceLayer.query
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.principal
|
||||
|
||||
internal fun ApplicationCall.getPlayerCredentials() =
|
||||
principal<JWTPrincipal>()!!.run {
|
||||
Player(
|
||||
id = payload.getClaim("playerid").asString(),
|
||||
name = payload.getClaim("username").asString(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package eventDemo.adapter.interfaceLayer.query
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
import eventDemo.configuration.serializer.GameIdSerializer
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.resources.Resource
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.resources.get
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Resource("/games/{id}")
|
||||
class Game(
|
||||
@Serializable(with = GameIdSerializer::class)
|
||||
val id: GameId,
|
||||
) {
|
||||
@Serializable
|
||||
@Resource("card/last")
|
||||
class Card(
|
||||
val game: Game,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Resource("state")
|
||||
class State(
|
||||
val game: Game,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* API routes to read the game state.
|
||||
*/
|
||||
fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
|
||||
authenticate {
|
||||
// Read the last played card on the game.
|
||||
get<Game.Card> { body ->
|
||||
gameStateRepository
|
||||
.get(body.game.id)
|
||||
.cardOnCurrentStack
|
||||
?.let { call.respond(it) }
|
||||
?: call.response.status(HttpStatusCode.BadRequest)
|
||||
}
|
||||
|
||||
// Read the last played card on the game.
|
||||
get<Game.State> { body ->
|
||||
val state = gameStateRepository.get(body.game.id)
|
||||
call.respond(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eventDemo.contexts.game.application.command.handlers
|
||||
package eventDemo.business.command
|
||||
|
||||
class CommandException(
|
||||
override val message: String,
|
||||
@@ -0,0 +1,27 @@
|
||||
package eventDemo.business.command
|
||||
|
||||
import eventDemo.business.command.action.ICantPlay
|
||||
import eventDemo.business.command.action.IWantToJoinTheGame
|
||||
import eventDemo.business.command.action.IWantToPlayCard
|
||||
import eventDemo.business.command.action.IamReadyToPlay
|
||||
import eventDemo.business.command.command.GameCommand
|
||||
import eventDemo.business.command.command.ICantPlayCommand
|
||||
import eventDemo.business.command.command.IWantToJoinTheGameCommand
|
||||
import eventDemo.business.command.command.IWantToPlayCardCommand
|
||||
import eventDemo.business.command.command.IamReadyToPlayCommand
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
|
||||
class GameCommandActionRunner(
|
||||
private val iWantToPlayCard: IWantToPlayCard,
|
||||
private val iamReadyToPlay: IamReadyToPlay,
|
||||
private val iWantToJoinTheGame: IWantToJoinTheGame,
|
||||
private val iCantPlay: ICantPlay,
|
||||
) {
|
||||
fun run(command: GameCommand): (version: Int) -> GameEvent =
|
||||
when (command) {
|
||||
is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
|
||||
is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
|
||||
is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
|
||||
is ICantPlayCommand -> iCantPlay.run(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package eventDemo.business.command
|
||||
|
||||
import eventDemo.business.command.command.GameCommand
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.GameEventStore
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.notification.CommandErrorNotification
|
||||
import eventDemo.business.notification.CommandSuccessNotification
|
||||
import eventDemo.business.notification.Notification
|
||||
import eventDemo.libs.command.CommandHandler
|
||||
import eventDemo.libs.command.CommandRunnerController
|
||||
import eventDemo.libs.event.EventHandlerImpl
|
||||
import eventDemo.libs.event.VersionBuilder
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
|
||||
/**
|
||||
* Listen [GameCommand] on [GameEventBus], check the validity and execute an action.
|
||||
*
|
||||
* This action can be executing an action and produce a new [GameEvent] after verification.
|
||||
*/
|
||||
class GameCommandHandler(
|
||||
eventBus: GameEventBus,
|
||||
eventStore: GameEventStore,
|
||||
versionBuilder: VersionBuilder,
|
||||
runner: GameCommandActionRunner,
|
||||
) {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
private val eventHandler =
|
||||
EventHandlerImpl(
|
||||
eventBus,
|
||||
eventStore,
|
||||
versionBuilder,
|
||||
)
|
||||
private val commandHandler =
|
||||
CommandHandler(
|
||||
CommandRunnerController<GameCommand>(),
|
||||
eventHandler,
|
||||
) {
|
||||
runner.run(it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the [event bus][GameEventBus]
|
||||
* to send success [notification][Notification] after save the [event][GameEvent].
|
||||
*/
|
||||
fun subscribeToBus(eventBus: GameEventBus) =
|
||||
commandHandler.subscribeToBus(eventBus)
|
||||
|
||||
/**
|
||||
* Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel],
|
||||
* run the command and publish the generated [event][GameEvent] to the bus.
|
||||
*
|
||||
* It restricts to run only once a command.
|
||||
*
|
||||
* If the command fail, send an [error notification][CommandErrorNotification],
|
||||
* if success, send a [success notification][CommandSuccessNotification]
|
||||
*/
|
||||
suspend fun handleIncomingPlayerCommands(
|
||||
player: Player,
|
||||
gameId: GameId,
|
||||
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
||||
channelNotification: SendChannel<Notification>,
|
||||
) {
|
||||
for (command in incomingCommandChannel) {
|
||||
handle(
|
||||
player,
|
||||
gameId,
|
||||
command,
|
||||
channelNotification.sendSuccess(command),
|
||||
channelNotification.sendError(command),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the [command] and publish the generated [event][GameEvent] to the bus.
|
||||
*
|
||||
* It restricts to run only once a command.
|
||||
*
|
||||
* If the command fail, send an [error notification][CommandErrorNotification],
|
||||
* if success, send a [success notification][CommandSuccessNotification]
|
||||
*/
|
||||
fun handle(
|
||||
player: Player,
|
||||
gameId: GameId,
|
||||
command: GameCommand,
|
||||
sendSuccess: () -> Unit,
|
||||
sendError: (message: String) -> Unit,
|
||||
) {
|
||||
if (command.payload.aggregateId.id != gameId.id) {
|
||||
logger.warn { "Handle command Refuse, the gameId of the command is not the same" }
|
||||
sendError("The gameId in the command does not match with your game")
|
||||
return
|
||||
}
|
||||
if (command.payload.player.id != player.id) {
|
||||
logger.warn { "Handle command Refuse, the player of the command is not the same" }
|
||||
sendError("You are not the author of this command")
|
||||
return
|
||||
}
|
||||
|
||||
commandHandler.handle(gameId, command) { _, error ->
|
||||
if (error != null) {
|
||||
sendError(error.message) // Business
|
||||
} else {
|
||||
sendSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SendChannel<Notification>.sendSuccess(command: GameCommand): () -> Unit =
|
||||
{
|
||||
val logger = KotlinLogging.logger { }
|
||||
CommandSuccessNotification(commandId = command.id)
|
||||
.also { notification ->
|
||||
withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) {
|
||||
logger.debug { "Notification SUCCESS sent" }
|
||||
trySend(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SendChannel<Notification>.sendError(command: GameCommand): (message: String) -> Unit =
|
||||
{
|
||||
val logger = KotlinLogging.logger { }
|
||||
CommandErrorNotification(message = it, command = command)
|
||||
.also { notification ->
|
||||
withLoggingContext("notification" to notification.toString(), "command" to command.toString()) {
|
||||
logger.warn { "Notification ERROR sent: ${notification.message}" }
|
||||
trySend(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.libs.command.Command
|
||||
import eventDemo.libs.event.Event
|
||||
|
||||
sealed interface CommandAction<C : Command, E : Event<*>> {
|
||||
fun run(command: C): (version: Int) -> E
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.ICantPlayCommand
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
data class ICantPlay(
|
||||
private val gameStateRepository: GameStateRepository,
|
||||
) : CommandAction<ICantPlayCommand, PlayerHavePassEvent> {
|
||||
override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent {
|
||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
||||
|
||||
if (state.currentPlayerTurn != command.payload.player) {
|
||||
throw CommandException("Its not your turn!")
|
||||
}
|
||||
|
||||
val playableCards = state.playableCards(command.payload.player)
|
||||
if (playableCards.isNotEmpty()) {
|
||||
throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
|
||||
}
|
||||
|
||||
val takenCard = state.deck.stack.first()
|
||||
return { version ->
|
||||
PlayerHavePassEvent(
|
||||
aggregateId = command.payload.aggregateId,
|
||||
player = command.payload.player,
|
||||
takenCard = takenCard,
|
||||
version = version,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.IWantToJoinTheGameCommand
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
data class IWantToJoinTheGame(
|
||||
private val gameStateRepository: GameStateRepository,
|
||||
) : CommandAction<IWantToJoinTheGameCommand, NewPlayerEvent> {
|
||||
override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent {
|
||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
||||
if (!state.isStarted) {
|
||||
return {
|
||||
NewPlayerEvent(
|
||||
aggregateId = command.payload.aggregateId,
|
||||
player = command.payload.player,
|
||||
version = it,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw CommandException("The game is already started")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.IWantToPlayCardCommand
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
data class IWantToPlayCard(
|
||||
private val gameStateRepository: GameStateRepository,
|
||||
) : CommandAction<IWantToPlayCardCommand, CardIsPlayedEvent> {
|
||||
override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent {
|
||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
||||
|
||||
if (!state.isStarted) {
|
||||
throw CommandException("The game is Not started")
|
||||
}
|
||||
if (state.currentPlayerTurn != command.payload.player) {
|
||||
throw CommandException("Its not your turn!")
|
||||
}
|
||||
if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) {
|
||||
throw CommandException("You cannot play this card")
|
||||
}
|
||||
|
||||
return { version ->
|
||||
CardIsPlayedEvent(
|
||||
aggregateId = command.payload.aggregateId,
|
||||
card = command.payload.card,
|
||||
player = command.payload.player,
|
||||
version = version,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eventDemo.business.command.action
|
||||
|
||||
import eventDemo.business.command.CommandException
|
||||
import eventDemo.business.command.command.IamReadyToPlayCommand
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
|
||||
/**
|
||||
* A command to set as ready to play
|
||||
*/
|
||||
class IamReadyToPlay(
|
||||
private val gameStateRepository: GameStateRepository,
|
||||
) : CommandAction<IamReadyToPlayCommand, PlayerReadyEvent> {
|
||||
@Throws(CommandException::class)
|
||||
override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent {
|
||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
||||
val playerExist: Boolean = state.players.contains(command.payload.player)
|
||||
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player)
|
||||
|
||||
if (state.isStarted) {
|
||||
throw CommandException("The game is already started")
|
||||
} else if (!playerExist) {
|
||||
throw CommandException("You are not in the game")
|
||||
} else if (playerIsAlreadyReady) {
|
||||
throw CommandException("You are already ready")
|
||||
} else {
|
||||
return { version: Int ->
|
||||
PlayerReadyEvent(
|
||||
aggregateId = command.payload.aggregateId,
|
||||
player = command.payload.player,
|
||||
version = version,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.Command
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface GameCommand : Command {
|
||||
val payload: Payload
|
||||
|
||||
@Serializable
|
||||
sealed interface Payload {
|
||||
val aggregateId: GameId
|
||||
val player: Player
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -1,24 +1,22 @@
|
||||
package eventDemo.contexts.game.application.command.models
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.contexts.game.domain.game.GameId
|
||||
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import eventDemo.sharedKernel.UserId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
@Serializable
|
||||
data class JoinTheGameCommand(
|
||||
override val userId: UserId,
|
||||
data class ICantPlayCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
@Serializable(with = GameIdSerializer::class)
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
@Serializable
|
||||
data class IWantToJoinTheGameCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to perform an action to play a new card
|
||||
*/
|
||||
@Serializable
|
||||
data class IWantToPlayCardCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
val card: Card,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eventDemo.business.command.command
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A command to set as ready to play
|
||||
*/
|
||||
@Serializable
|
||||
data class IamReadyToPlayCommand(
|
||||
override val payload: Payload,
|
||||
) : GameCommand {
|
||||
override val id: CommandId = CommandId()
|
||||
|
||||
@Serializable
|
||||
data class Payload(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
) : GameCommand.Payload
|
||||
}
|
||||
+13
-31
@@ -1,6 +1,6 @@
|
||||
package eventDemo.contexts.game.domain.game
|
||||
package eventDemo.business.entity
|
||||
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
@@ -23,7 +23,7 @@ sealed interface Card {
|
||||
Green,
|
||||
}
|
||||
|
||||
sealed interface CardWithColor : Card {
|
||||
sealed interface ColorCard : Card {
|
||||
val color: Color
|
||||
}
|
||||
|
||||
@@ -38,14 +38,11 @@ sealed interface Card {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Card,
|
||||
CardWithColor {
|
||||
ColorCard {
|
||||
init {
|
||||
if (number > 9) error("Card number cannot be greater of 9")
|
||||
if (number < 0) error("Card number cannot be lower of 0")
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
"Numeric Card $number $color"
|
||||
}
|
||||
|
||||
sealed interface Special : Card
|
||||
@@ -60,10 +57,7 @@ sealed interface Card {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
CardWithColor {
|
||||
override fun toString(): String =
|
||||
"Revert Card $color"
|
||||
}
|
||||
ColorCard
|
||||
|
||||
sealed interface PassTurnCard : Card
|
||||
|
||||
@@ -77,11 +71,8 @@ sealed interface Card {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
CardWithColor,
|
||||
PassTurnCard {
|
||||
override fun toString(): String =
|
||||
"Pass Card $color"
|
||||
}
|
||||
ColorCard,
|
||||
PassTurnCard
|
||||
|
||||
/**
|
||||
* A play card to force the next player to take 2 card and pass the turn.
|
||||
@@ -93,13 +84,10 @@ sealed interface Card {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
CardWithColor,
|
||||
PassTurnCard {
|
||||
override fun toString(): String =
|
||||
"Plus2 Card $color"
|
||||
}
|
||||
ColorCard,
|
||||
PassTurnCard
|
||||
|
||||
sealed interface CardWith4Color : Card
|
||||
sealed interface AllColorCard : Card
|
||||
|
||||
/**
|
||||
* A play card to force the next player to take 4 card and pass the turn.
|
||||
@@ -110,11 +98,8 @@ sealed interface Card {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
CardWith4Color,
|
||||
PassTurnCard {
|
||||
override fun toString(): String =
|
||||
"Plus4 Card"
|
||||
}
|
||||
AllColorCard,
|
||||
PassTurnCard
|
||||
|
||||
/**
|
||||
* A play card to change the color.
|
||||
@@ -125,8 +110,5 @@ sealed interface Card {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : Special,
|
||||
CardWith4Color {
|
||||
override fun toString(): String =
|
||||
"Change color Card"
|
||||
}
|
||||
AllColorCard
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Deck(
|
||||
val stack: Stack = Stack(),
|
||||
val discard: Discard = Discard(),
|
||||
val playersHands: PlayersHands = PlayersHands(),
|
||||
) {
|
||||
constructor(players: Set<Player>) :
|
||||
this(playersHands = PlayersHands(players))
|
||||
|
||||
fun shuffle(): Deck =
|
||||
copy(stack = stack.shuffle())
|
||||
|
||||
fun placeFirstCardOnDiscard(): Deck {
|
||||
val takenCard = stack.first()
|
||||
return copy(
|
||||
stack = stack - takenCard,
|
||||
discard = discard + takenCard,
|
||||
)
|
||||
}
|
||||
|
||||
fun takeOneCardFromStackTo(player: Player): Deck =
|
||||
takeOne().let { (deck, newPlayerCard) ->
|
||||
deck.copy(
|
||||
playersHands = deck.playersHands.addCard(player, newPlayerCard),
|
||||
)
|
||||
}
|
||||
|
||||
fun putOneCardFromHand(
|
||||
player: Player,
|
||||
card: Card,
|
||||
): Deck =
|
||||
run {
|
||||
// Validate parameters
|
||||
val playerHand =
|
||||
playersHands.getHand(player)
|
||||
?: error("No player on this game")
|
||||
if (playerHand.none { it == card }) {
|
||||
error("No card exist on the player hand")
|
||||
}
|
||||
}.let {
|
||||
copy(
|
||||
discard = discard + card,
|
||||
playersHands = playersHands.removeCard(player, card),
|
||||
)
|
||||
}
|
||||
|
||||
fun playerHasNoCardLeft(): List<Player.PlayerId> =
|
||||
playersHands
|
||||
.filter { (playerId, hand) -> hand.isEmpty() }
|
||||
.map { (playerId, hand) -> playerId }
|
||||
|
||||
private fun take(n: Int): Pair<Deck, List<Card>> {
|
||||
val takenCards = stack.take(n)
|
||||
val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
|
||||
return Pair(copy(stack = newStack), takenCards)
|
||||
}
|
||||
|
||||
private fun takeOne(): Pair<Deck, Card> =
|
||||
take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
|
||||
|
||||
companion object {
|
||||
fun newWithoutPlayers(): Deck =
|
||||
listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green)
|
||||
.flatMap { color ->
|
||||
((0..9) + (1..9)).map { Card.NumericCard(it, color) } +
|
||||
(1..2).map { Card.Plus2Card(color) } +
|
||||
(1..2).map { Card.ReverseCard(color) } +
|
||||
(1..2).map { Card.PassCard(color) }
|
||||
}.let {
|
||||
it + (1..4).map { Card.Plus4Card() }
|
||||
}.toStack()
|
||||
.let { Deck(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun Deck.initHands(
|
||||
players: Set<Player>,
|
||||
handSize: Int = 7,
|
||||
): Deck {
|
||||
// Copy cards from stack to the player hands
|
||||
val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
|
||||
return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
|
||||
val hand = acc.stack.take(handSize)
|
||||
val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
|
||||
copy(
|
||||
stack = newStack,
|
||||
playersHands = acc.playersHands.addCards(player, hand),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class Stack(
|
||||
private val cards: Set<Card> = emptySet(),
|
||||
) : Set<Card> by cards {
|
||||
operator fun plus(card: Card): Stack =
|
||||
cards.plus(card).toStack()
|
||||
|
||||
operator fun minus(card: Card): Stack =
|
||||
cards.minus(card).toStack()
|
||||
|
||||
fun shuffle(): Stack =
|
||||
shuffled().toStack()
|
||||
}
|
||||
|
||||
fun List<Card>.toStack(): Stack =
|
||||
Stack(this.toSet())
|
||||
|
||||
fun Set<Card>.toStack(): Stack =
|
||||
Stack(this)
|
||||
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class Discard(
|
||||
private val cards: Set<Card> = emptySet(),
|
||||
) : Set<Card> by cards {
|
||||
operator fun plus(card: Card): Discard =
|
||||
cards.plus(card).toDiscard()
|
||||
|
||||
operator fun minus(card: Card): Discard =
|
||||
cards.minus(card).toDiscard()
|
||||
}
|
||||
|
||||
fun List<Card>.toDiscard(): Discard =
|
||||
Discard(this.toSet())
|
||||
|
||||
fun Set<Card>.toDiscard(): Discard =
|
||||
Discard(this)
|
||||
+4
-5
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.domain.game
|
||||
package eventDemo.business.entity
|
||||
|
||||
import eventDemo.libs.eventSource.AggregateId
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.configuration.serializer.GameIdSerializer
|
||||
import eventDemo.libs.event.AggregateId
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -9,9 +9,8 @@ import java.util.UUID
|
||||
* An [AggregateId] for a game.
|
||||
*/
|
||||
@JvmInline
|
||||
@Serializable
|
||||
@Serializable(with = GameIdSerializer::class)
|
||||
value class GameId(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : AggregateId {
|
||||
override fun toString(): String =
|
||||
@@ -0,0 +1,29 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import eventDemo.configuration.serializer.PlayerIdSerializer
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import eventDemo.libs.event.AggregateId
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class Player(
|
||||
val name: String,
|
||||
@Serializable(with = PlayerIdSerializer::class)
|
||||
val id: PlayerId = PlayerId(UUID.randomUUID()),
|
||||
) {
|
||||
constructor(id: String, name: String) : this(
|
||||
name,
|
||||
PlayerId(UUID.fromString(id)),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PlayerId(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
) : AggregateId {
|
||||
override fun toString(): String =
|
||||
id.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package eventDemo.business.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class PlayersHands(
|
||||
private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
|
||||
) : Map<Player.PlayerId, List<Card>> by map {
|
||||
constructor(players: Set<Player>) :
|
||||
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
|
||||
|
||||
fun getHand(player: Player): List<Card>? =
|
||||
this[player.id]
|
||||
|
||||
fun removeCard(
|
||||
player: Player,
|
||||
card: Card,
|
||||
): PlayersHands =
|
||||
mapValues { (playerId, cards) ->
|
||||
if (playerId == player.id) {
|
||||
if (!cards.contains(card)) error("The hand no contain the card")
|
||||
cards - card
|
||||
} else {
|
||||
cards
|
||||
}
|
||||
}.toPlayersHands()
|
||||
|
||||
fun addCard(
|
||||
player: Player,
|
||||
newCard: Card,
|
||||
): PlayersHands =
|
||||
addCards(player, listOf(newCard))
|
||||
|
||||
fun addCards(
|
||||
player: Player,
|
||||
newCards: List<Card>,
|
||||
): PlayersHands =
|
||||
mapValues { (p, cards) ->
|
||||
if (p == player.id) {
|
||||
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
|
||||
cards + newCards
|
||||
} else {
|
||||
cards
|
||||
}
|
||||
}.toPlayersHands()
|
||||
}
|
||||
|
||||
fun Map<Player.PlayerId, List<Card>>.toPlayersHands(): PlayersHands =
|
||||
PlayersHands(this)
|
||||
@@ -0,0 +1,6 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.bus.Bus
|
||||
|
||||
interface GameEventBus : Bus<GameEvent>
|
||||
@@ -0,0 +1,16 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.EventHandler
|
||||
import eventDemo.libs.event.EventHandlerImpl
|
||||
import eventDemo.libs.event.VersionBuilder
|
||||
|
||||
/**
|
||||
* Handle the event to dispatch it to store, bus and projections builders
|
||||
*/
|
||||
class GameEventHandler(
|
||||
private val eventBus: GameEventBus,
|
||||
private val eventStore: GameEventStore,
|
||||
private val versionBuilder: VersionBuilder,
|
||||
) : EventHandler<GameEvent, GameId> by EventHandlerImpl(eventBus, eventStore, versionBuilder)
|
||||
@@ -0,0 +1,7 @@
|
||||
package eventDemo.business.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.EventStore
|
||||
|
||||
interface GameEventStore : EventStore<GameEvent, GameId>
|
||||
@@ -0,0 +1,26 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [GameEvent] to represent a played card.
|
||||
*/
|
||||
@Serializable
|
||||
data class CardIsPlayedEvent(
|
||||
override val aggregateId: GameId,
|
||||
val card: Card,
|
||||
override val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent,
|
||||
PlayerActionEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.libs.event.Event
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [Event] of a Game.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface GameEvent : Event<GameId> {
|
||||
override val eventId: UUID
|
||||
override val aggregateId: GameId
|
||||
override val version: Int
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Deck
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.entity.initHands
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when all players are ready.
|
||||
*/
|
||||
@Serializable
|
||||
data class GameStartedEvent(
|
||||
override val aggregateId: GameId,
|
||||
val firstPlayer: Player,
|
||||
val deck: Deck,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
|
||||
companion object {
|
||||
fun new(
|
||||
id: GameId,
|
||||
players: Set<Player>,
|
||||
version: Int,
|
||||
shuffleIsDisabled: Boolean = isDisabled,
|
||||
): GameStartedEvent =
|
||||
GameStartedEvent(
|
||||
aggregateId = id,
|
||||
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
|
||||
deck =
|
||||
Deck
|
||||
.newWithoutPlayers()
|
||||
.let { if (shuffleIsDisabled) it else it.shuffle() }
|
||||
.initHands(players)
|
||||
.placeFirstCardOnDiscard(),
|
||||
version = version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var isDisabled = false
|
||||
|
||||
internal fun disableShuffleDeck() {
|
||||
isDisabled = true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* An [GameEvent] to represent a new player joining the game.
|
||||
*/
|
||||
@Serializable
|
||||
data class NewPlayerEvent(
|
||||
override val aggregateId: GameId,
|
||||
val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Player
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface PlayerActionEvent : GameEvent {
|
||||
val player: Player
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player chose a color.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerChoseColorEvent(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
val color: Card.Color,
|
||||
override val version: Int,
|
||||
) : GameEvent,
|
||||
PlayerActionEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player can play.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerHavePassEvent(
|
||||
override val aggregateId: GameId,
|
||||
override val player: Player,
|
||||
val takenCard: Card,
|
||||
override val version: Int,
|
||||
) : GameEvent,
|
||||
PlayerActionEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player is ready.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerReadyEvent(
|
||||
override val aggregateId: GameId,
|
||||
val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eventDemo.business.event.event
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* This [GameEvent] is sent when a player is ready.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerWinEvent(
|
||||
override val aggregateId: GameId,
|
||||
val player: Player,
|
||||
override val version: Int,
|
||||
) : GameEvent {
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val eventId: UUID = UUID.randomUUID()
|
||||
override val createdAt: Instant = Clock.System.now()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.libs.event.projection.Projection
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface GameProjection : Projection<GameId>
|
||||
@@ -0,0 +1,5 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.libs.bus.Bus
|
||||
|
||||
interface GameProjectionBus : Bus<GameProjection>
|
||||
+7
-5
@@ -1,7 +1,8 @@
|
||||
package eventDemo.contexts.game.infrastructure.persistence.projections.models
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.contexts.game.domain.game.GameId
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.libs.event.projection.Projection
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -9,10 +10,11 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
@Serializable
|
||||
data class GameList(
|
||||
val aggregateId: GameId,
|
||||
override val aggregateId: GameId,
|
||||
override val lastEventVersion: Int = 0,
|
||||
val status: Status = Status.OPENING,
|
||||
val players: Set<Player> = emptySet(),
|
||||
val winners: Set<Player.PlayerId> = emptySet(),
|
||||
val winners: Set<Player> = emptySet(),
|
||||
) : GameProjection {
|
||||
enum class Status {
|
||||
OPENING,
|
||||
@@ -0,0 +1,51 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
|
||||
fun GameList.apply(event: GameEvent): GameList =
|
||||
when (event) {
|
||||
is NewPlayerEvent -> {
|
||||
copy(
|
||||
players = players + event.player,
|
||||
status = GameList.Status.OPENING,
|
||||
)
|
||||
}
|
||||
|
||||
is GameStartedEvent -> {
|
||||
copy(
|
||||
status = GameList.Status.IS_STARTED,
|
||||
)
|
||||
}
|
||||
|
||||
is PlayerWinEvent -> {
|
||||
copy(
|
||||
winners = winners + event.player,
|
||||
status = GameList.Status.FINISH,
|
||||
)
|
||||
}
|
||||
|
||||
is CardIsPlayedEvent -> {
|
||||
this
|
||||
}
|
||||
|
||||
is PlayerChoseColorEvent -> {
|
||||
this
|
||||
}
|
||||
|
||||
is PlayerHavePassEvent -> {
|
||||
this
|
||||
}
|
||||
|
||||
is PlayerReadyEvent -> {
|
||||
this
|
||||
}
|
||||
}.copy(
|
||||
lastEventVersion = event.version,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
interface GameListRepository {
|
||||
fun getList(): List<GameList>
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Deck
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.libs.event.projection.Projection
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* This [projection][Projection] is used for manage a game and theirs [card][Card]
|
||||
*/
|
||||
@Serializable
|
||||
data class GameState(
|
||||
override val aggregateId: GameId,
|
||||
override val lastEventVersion: Int = 0,
|
||||
val players: Set<Player> = emptySet(),
|
||||
val currentPlayerTurn: Player? = null,
|
||||
val lastCardPlayer: Player? = null,
|
||||
val colorOnCurrentStack: Card.Color? = null,
|
||||
val direction: Direction = Direction.CLOCKWISE,
|
||||
val readyPlayers: Set<Player> = emptySet(),
|
||||
val deck: Deck = Deck(players),
|
||||
val isStarted: Boolean = false,
|
||||
val playerWins: Set<Player> = emptySet(),
|
||||
val lastEvent: GameEvent? = null,
|
||||
) : GameProjection {
|
||||
enum class Direction {
|
||||
CLOCKWISE,
|
||||
COUNTER_CLOCKWISE,
|
||||
;
|
||||
|
||||
fun revert(): Direction =
|
||||
if (this === CLOCKWISE) {
|
||||
COUNTER_CLOCKWISE
|
||||
} else {
|
||||
CLOCKWISE
|
||||
}
|
||||
}
|
||||
|
||||
val cardOnCurrentStack: Card? = deck.discard.lastOrNull()
|
||||
|
||||
val isReady: Boolean get() {
|
||||
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
|
||||
}
|
||||
|
||||
private val currentPlayerIndex: Int? get() {
|
||||
val i = players.indexOf(currentPlayerTurn)
|
||||
return if (i == -1) {
|
||||
null
|
||||
} else {
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextPlayerIndex(direction: Direction): Int {
|
||||
if (players.isEmpty()) return 0
|
||||
|
||||
return if (direction == Direction.CLOCKWISE) {
|
||||
sidePlayerIndexClockwise
|
||||
} else {
|
||||
sidePlayerIndexCounterClockwise
|
||||
}
|
||||
}
|
||||
|
||||
fun nextPlayer(direction: Direction): Player =
|
||||
players.elementAt(nextPlayerIndex(direction))
|
||||
|
||||
private val sidePlayerIndexClockwise: Int by lazy {
|
||||
if (players.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
((currentPlayerIndex ?: 0) + 1) % players.size
|
||||
}
|
||||
}
|
||||
private val sidePlayerIndexCounterClockwise: Int by lazy {
|
||||
if (players.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
((currentPlayerIndex ?: 0) - 1) % players.size
|
||||
}
|
||||
}
|
||||
|
||||
val nextPlayerTurn: Player? by lazy {
|
||||
if (players.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
nextPlayer(direction)
|
||||
}
|
||||
}
|
||||
|
||||
private val Player.currentIndex: Int get() = players.indexOf(this)
|
||||
|
||||
fun Player.playerDiffIndex(nextPlayer: Player): Int =
|
||||
if (direction == Direction.CLOCKWISE) {
|
||||
nextPlayer.currentIndex + this.currentIndex
|
||||
} else {
|
||||
nextPlayer.currentIndex - this.currentIndex
|
||||
}.let { it % players.size }
|
||||
|
||||
val Player.cardOnBoardIsForYou: Boolean get() {
|
||||
if (lastCardPlayer == null) error("No card")
|
||||
return this.playerDiffIndex(lastCardPlayer) == 1
|
||||
}
|
||||
|
||||
fun playableCards(player: Player): List<Card> =
|
||||
deck
|
||||
.playersHands
|
||||
.getHand(player)
|
||||
?.filter { canBePlayThisCard(player, it) }
|
||||
?: emptyList()
|
||||
|
||||
fun playerHasNoCardLeft(): List<Player> =
|
||||
deck.playerHasNoCardLeft().map { playerId ->
|
||||
players.find { it.id == playerId } ?: error("inconsistency detected between players")
|
||||
}
|
||||
|
||||
fun canBePlayThisCard(
|
||||
player: Player,
|
||||
card: Card,
|
||||
): Boolean {
|
||||
val cardOnBoard = cardOnCurrentStack ?: return false
|
||||
return when (cardOnBoard) {
|
||||
is Card.NumericCard -> {
|
||||
when (card) {
|
||||
is Card.AllColorCard -> true
|
||||
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
|
||||
is Card.ColorCard -> card.color == cardOnBoard.color
|
||||
}
|
||||
}
|
||||
|
||||
is Card.ReverseCard -> {
|
||||
when (card) {
|
||||
is Card.ReverseCard -> true
|
||||
is Card.AllColorCard -> true
|
||||
is Card.ColorCard -> card.color == cardOnBoard.color
|
||||
}
|
||||
}
|
||||
|
||||
is Card.PassCard -> {
|
||||
if (player.cardOnBoardIsForYou) {
|
||||
false
|
||||
} else {
|
||||
when (card) {
|
||||
is Card.AllColorCard -> true
|
||||
is Card.ColorCard -> card.color == cardOnBoard.color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Card.ChangeColorCard -> {
|
||||
when (card) {
|
||||
is Card.AllColorCard -> true
|
||||
is Card.ColorCard -> card.color == colorOnCurrentStack
|
||||
}
|
||||
}
|
||||
|
||||
is Card.Plus2Card -> {
|
||||
if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
|
||||
true
|
||||
} else {
|
||||
when (card) {
|
||||
is Card.Plus2Card -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Card.Plus4Card -> {
|
||||
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
|
||||
true
|
||||
} else {
|
||||
when (card) {
|
||||
is Card.AllColorCard -> true
|
||||
is Card.ColorCard -> card.color == colorOnCurrentStack
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.event.event.GameEvent
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.event.event.PlayerActionEvent
|
||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
fun GameState.apply(event: GameEvent): GameState =
|
||||
this.let { state ->
|
||||
val logger = KotlinLogging.logger { }
|
||||
if (event is PlayerActionEvent) {
|
||||
if (state.currentPlayerTurn != event.player) {
|
||||
logger.atError {
|
||||
message = "Inconsistent player turn"
|
||||
payload =
|
||||
mapOf(
|
||||
"CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
|
||||
"Player" to event.player,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (event) {
|
||||
is CardIsPlayedEvent -> {
|
||||
val nextDirectionAfterPlay =
|
||||
when (event.card) {
|
||||
is Card.ReverseCard -> state.direction.revert()
|
||||
else -> state.direction
|
||||
}
|
||||
|
||||
val color =
|
||||
when (event.card) {
|
||||
is Card.ColorCard -> event.card.color
|
||||
is Card.AllColorCard -> null
|
||||
}
|
||||
|
||||
val currentPlayerAfterThePlay =
|
||||
if (event.card is Card.AllColorCard) {
|
||||
state.currentPlayerTurn
|
||||
} else {
|
||||
state.nextPlayer(nextDirectionAfterPlay)
|
||||
}
|
||||
|
||||
state.copy(
|
||||
currentPlayerTurn = currentPlayerAfterThePlay,
|
||||
direction = nextDirectionAfterPlay,
|
||||
colorOnCurrentStack = color,
|
||||
lastCardPlayer = event.player,
|
||||
deck = state.deck.putOneCardFromHand(event.player, event.card),
|
||||
)
|
||||
}
|
||||
|
||||
is NewPlayerEvent -> {
|
||||
if (state.isStarted) {
|
||||
logger.error { "The game is already started" }
|
||||
}
|
||||
|
||||
state.copy(
|
||||
players = state.players + event.player,
|
||||
)
|
||||
}
|
||||
|
||||
is PlayerReadyEvent -> {
|
||||
if (state.isStarted) {
|
||||
logger.error { "The game is already started" }
|
||||
}
|
||||
state.copy(
|
||||
readyPlayers = state.readyPlayers + event.player,
|
||||
)
|
||||
}
|
||||
|
||||
is PlayerHavePassEvent -> {
|
||||
if (event.takenCard != state.deck.stack.first()) {
|
||||
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
|
||||
}
|
||||
state.copy(
|
||||
currentPlayerTurn = state.nextPlayerTurn,
|
||||
deck = state.deck.takeOneCardFromStackTo(event.player),
|
||||
)
|
||||
}
|
||||
|
||||
is PlayerChoseColorEvent -> {
|
||||
state.copy(
|
||||
currentPlayerTurn = state.nextPlayerTurn,
|
||||
colorOnCurrentStack = event.color,
|
||||
)
|
||||
}
|
||||
|
||||
is GameStartedEvent -> {
|
||||
state.copy(
|
||||
colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
|
||||
lastCardPlayer = null,
|
||||
currentPlayerTurn = event.firstPlayer,
|
||||
deck = event.deck,
|
||||
isStarted = true,
|
||||
)
|
||||
}
|
||||
|
||||
is PlayerWinEvent -> {
|
||||
state.copy(
|
||||
playerWins = state.playerWins + event.player,
|
||||
)
|
||||
}
|
||||
}.copy(
|
||||
lastEventVersion = event.version,
|
||||
lastEvent = event,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eventDemo.business.event.projection
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
|
||||
interface GameStateRepository {
|
||||
fun get(gameId: GameId): GameState
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package eventDemo.business.event.projection.projectionListener
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.NewPlayerEvent
|
||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
||||
import eventDemo.business.event.event.PlayerReadyEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.business.notification.ItsTheTurnOfNotification
|
||||
import eventDemo.business.notification.Notification
|
||||
import eventDemo.business.notification.PlayerAsJoinTheGameNotification
|
||||
import eventDemo.business.notification.PlayerAsPlayACardNotification
|
||||
import eventDemo.business.notification.PlayerHavePassNotification
|
||||
import eventDemo.business.notification.PlayerWasChoseTheCardColorNotification
|
||||
import eventDemo.business.notification.PlayerWasReadyNotification
|
||||
import eventDemo.business.notification.PlayerWinNotification
|
||||
import eventDemo.business.notification.TheGameWasStartedNotification
|
||||
import eventDemo.business.notification.WelcomeToTheGameNotification
|
||||
import eventDemo.business.notification.YourNewCardNotification
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
|
||||
class PlayerNotificationListener(
|
||||
private val projectionBus: GameProjectionBus,
|
||||
) {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification]
|
||||
*/
|
||||
fun startListening(
|
||||
currentPlayer: Player,
|
||||
gameId: GameId,
|
||||
outgoingNotification: (Notification) -> Unit,
|
||||
): AutoCloseable {
|
||||
return projectionBus.subscribe { currentState ->
|
||||
if (currentState !is GameState) return@subscribe
|
||||
if (currentState.aggregateId != gameId) return@subscribe
|
||||
withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) {
|
||||
fun Notification.send() {
|
||||
withLoggingContext("notification" to this.toString()) {
|
||||
if (currentState.players.contains(currentPlayer)) {
|
||||
// Only notify players who have already joined the game.
|
||||
outgoingNotification(this)
|
||||
logger.info { "Notification was SEND" }
|
||||
} else {
|
||||
// Rare use case, when a connexion is created with the channel,
|
||||
// but the player was not already join in the game
|
||||
logger.warn { "Notification was SKIP, no player on the game" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNextTurnNotif() =
|
||||
ItsTheTurnOfNotification(
|
||||
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
|
||||
).send()
|
||||
|
||||
val event =
|
||||
currentState.lastEvent
|
||||
?: error("No last event in the GameState projection")
|
||||
|
||||
when (event) {
|
||||
is NewPlayerEvent -> {
|
||||
if (currentPlayer != event.player) {
|
||||
PlayerAsJoinTheGameNotification(
|
||||
player = event.player,
|
||||
).send()
|
||||
} else {
|
||||
WelcomeToTheGameNotification(
|
||||
players = currentState.players,
|
||||
).send()
|
||||
}
|
||||
}
|
||||
|
||||
is CardIsPlayedEvent -> {
|
||||
if (currentPlayer != event.player) {
|
||||
PlayerAsPlayACardNotification(
|
||||
player = event.player,
|
||||
card = event.card,
|
||||
).send()
|
||||
}
|
||||
|
||||
if (event.card !is Card.AllColorCard) {
|
||||
ItsTheTurnOfNotification(
|
||||
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
|
||||
).send()
|
||||
}
|
||||
}
|
||||
|
||||
is GameStartedEvent -> {
|
||||
TheGameWasStartedNotification(
|
||||
hand =
|
||||
event.deck.playersHands.getHand(currentPlayer)
|
||||
?: error("You are not in the game"),
|
||||
).send()
|
||||
|
||||
sendNextTurnNotif()
|
||||
}
|
||||
|
||||
is PlayerChoseColorEvent -> {
|
||||
if (currentPlayer != event.player) {
|
||||
PlayerWasChoseTheCardColorNotification(
|
||||
player = event.player,
|
||||
color = event.color,
|
||||
).send()
|
||||
}
|
||||
|
||||
sendNextTurnNotif()
|
||||
}
|
||||
|
||||
is PlayerHavePassEvent -> {
|
||||
if (currentPlayer == event.player) {
|
||||
YourNewCardNotification(
|
||||
card = event.takenCard,
|
||||
).send()
|
||||
} else {
|
||||
PlayerHavePassNotification(
|
||||
player = event.player,
|
||||
).send()
|
||||
}
|
||||
|
||||
sendNextTurnNotif()
|
||||
}
|
||||
|
||||
is PlayerReadyEvent -> {
|
||||
if (currentPlayer != event.player) {
|
||||
PlayerWasReadyNotification(
|
||||
player = event.player,
|
||||
).send()
|
||||
}
|
||||
}
|
||||
|
||||
is PlayerWinEvent -> {
|
||||
PlayerWinNotification(
|
||||
player = event.player,
|
||||
).send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package eventDemo.business.event.projection.projectionListener
|
||||
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.event.GameEventHandler
|
||||
import eventDemo.business.event.event.GameStartedEvent
|
||||
import eventDemo.business.event.event.PlayerWinEvent
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameState
|
||||
import eventDemo.libs.event.projection.Projection
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import java.util.concurrent.ConcurrentSkipListSet
|
||||
|
||||
class ReactionListener(
|
||||
private val eventHandler: GameEventHandler,
|
||||
) {
|
||||
companion object Config {
|
||||
val registeredListeners = ConcurrentSkipListSet<GameProjectionBus>()
|
||||
}
|
||||
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
fun subscribeToBus(projectionBus: GameProjectionBus) {
|
||||
if (registeredListeners.add(projectionBus)) {
|
||||
projectionBus.subscribe { projection: Projection<GameId> ->
|
||||
if (projection !is GameState) return@subscribe
|
||||
withLoggingContext("projection" to projection.toString()) {
|
||||
sendStartGameEvent(projection)
|
||||
sendWinnerEvent(projection)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"${this::class.simpleName} is already init for this bus".let {
|
||||
logger.error { it }
|
||||
error(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendStartGameEvent(state: GameState) {
|
||||
if (state.isReady && !state.isStarted) {
|
||||
val reactionEvent =
|
||||
eventHandler.handle(state.aggregateId) {
|
||||
GameStartedEvent.new(
|
||||
id = state.aggregateId,
|
||||
players = state.players,
|
||||
version = it,
|
||||
)
|
||||
}
|
||||
logger.atInfo {
|
||||
message = "Reaction event was Send"
|
||||
payload = mapOf("reactionEvent" to reactionEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendWinnerEvent(state: GameState) {
|
||||
val winner = state.playerHasNoCardLeft().firstOrNull()
|
||||
if (winner != null) {
|
||||
val reactionEvent =
|
||||
eventHandler.handle(state.aggregateId) {
|
||||
PlayerWinEvent(
|
||||
aggregateId = state.aggregateId,
|
||||
player = winner,
|
||||
version = it,
|
||||
)
|
||||
}
|
||||
|
||||
logger.atInfo {
|
||||
message = "Reaction event was Send"
|
||||
payload = mapOf("reactionEvent" to reactionEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import eventDemo.libs.command.Command
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class CommandErrorNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val message: String,
|
||||
val command: Command,
|
||||
) : Notification,
|
||||
CommandNotification
|
||||
@@ -0,0 +1,3 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
sealed interface CommandNotification : Notification
|
||||
@@ -0,0 +1,14 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import eventDemo.libs.command.CommandId
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class CommandSuccessNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val commandId: CommandId,
|
||||
) : Notification,
|
||||
CommandNotification
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Card
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -10,6 +10,6 @@ import java.util.UUID
|
||||
data class PlayerAsPlayACardNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val playerId: Player.PlayerId,
|
||||
val player: Player,
|
||||
val card: Card,
|
||||
) : Notification
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -9,5 +9,5 @@ import java.util.UUID
|
||||
data class PlayerHavePassNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val playerId: Player.PlayerId,
|
||||
val player: Player,
|
||||
) : Notification
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class PlayerWasChoseTheCardColorNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val player: Player,
|
||||
val color: Card.Color,
|
||||
) : Notification
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -9,5 +9,5 @@ import java.util.UUID
|
||||
data class PlayerWasReadyNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val playerId: Player.PlayerId,
|
||||
val player: Player,
|
||||
) : Notification
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -9,5 +9,5 @@ import java.util.UUID
|
||||
data class PlayerWinNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val playerId: Player.PlayerId,
|
||||
val player: Player,
|
||||
) : Notification
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Card
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -9,5 +9,5 @@ import java.util.UUID
|
||||
data class TheGameWasStartedNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val hand: Set<Card>,
|
||||
val hand: List<Card>,
|
||||
) : Notification
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
package eventDemo.contexts.game.application.notification.models
|
||||
package eventDemo.business.notification
|
||||
|
||||
import eventDemo.contexts.game.domain.game.Card
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import eventDemo.business.entity.Card
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -9,5 +9,5 @@ import java.util.UUID
|
||||
data class YourNewCardNotification(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
override val id: UUID = UUID.randomUUID(),
|
||||
val cards: Set<Card>,
|
||||
val card: Card,
|
||||
) : Notification
|
||||
@@ -1,46 +0,0 @@
|
||||
package eventDemo.configuration
|
||||
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
|
||||
data class Configuration(
|
||||
val redisUrl: String,
|
||||
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(
|
||||
redisUrl = getProperty("redis.url"),
|
||||
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")
|
||||
@@ -0,0 +1,29 @@
|
||||
package eventDemo.configuration
|
||||
|
||||
import eventDemo.configuration.business.configureGameListener
|
||||
import eventDemo.configuration.ktor.configureHttpRouting
|
||||
import eventDemo.configuration.ktor.configureKoin
|
||||
import eventDemo.configuration.ktor.configureSecurity
|
||||
import eventDemo.configuration.ktor.configureSerialization
|
||||
import eventDemo.configuration.ktor.configureWebSockets
|
||||
import eventDemo.configuration.route.declareHttpGameRoute
|
||||
import eventDemo.configuration.route.declareWebSocketsGameRoute
|
||||
import io.ktor.server.application.Application
|
||||
import org.koin.ktor.ext.get
|
||||
import org.koin.ktor.ext.getKoin
|
||||
|
||||
fun Application.configure() {
|
||||
configureKoin()
|
||||
|
||||
configureSecurity()
|
||||
|
||||
configureSerialization()
|
||||
|
||||
configureWebSockets()
|
||||
declareWebSocketsGameRoute(get(), get())
|
||||
|
||||
configureHttpRouting()
|
||||
declareHttpGameRoute()
|
||||
|
||||
getKoin().configureGameListener()
|
||||
}
|
||||
@@ -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,55 +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 redis.clients.jedis.JedisPooled
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
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
|
||||
|
||||
// Redis (for Projections)
|
||||
single {
|
||||
JedisPooled(config.redisUrl)
|
||||
} bind UnifiedJedis::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()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package eventDemo.configuration.business
|
||||
|
||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInRedis
|
||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInRedis
|
||||
import eventDemo.business.command.GameCommandHandler
|
||||
import eventDemo.business.event.projection.projectionListener.ReactionListener
|
||||
import org.koin.core.Koin
|
||||
|
||||
fun Koin.configureGameListener() {
|
||||
get<GameCommandHandler>()
|
||||
.subscribeToBus(get())
|
||||
|
||||
get<GameStateRepositoryInRedis>()
|
||||
.subscribeToBus(get(), get())
|
||||
|
||||
get<GameListRepositoryInRedis>()
|
||||
.subscribeToBus(get(), get())
|
||||
|
||||
get<ReactionListener>()
|
||||
.subscribeToBus(get())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package eventDemo.configuration.injection
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
fun appKoinModule(config: Configuration) =
|
||||
module {
|
||||
configureDIBusiness()
|
||||
configureDIInfrastructure(config)
|
||||
configureDILibs()
|
||||
configureDICommandActions()
|
||||
}
|
||||
|
||||
data class Configuration(
|
||||
val redisUrl: 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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eventDemo.configuration.injection
|
||||
|
||||
import eventDemo.business.command.action.ICantPlay
|
||||
import eventDemo.business.command.action.IWantToJoinTheGame
|
||||
import eventDemo.business.command.action.IWantToPlayCard
|
||||
import eventDemo.business.command.action.IamReadyToPlay
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
|
||||
/**
|
||||
* Configure all actions
|
||||
*/
|
||||
fun Module.configureDICommandActions() {
|
||||
singleOf(::IWantToPlayCard)
|
||||
singleOf(::IamReadyToPlay)
|
||||
singleOf(::IWantToJoinTheGame)
|
||||
singleOf(::ICantPlay)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eventDemo.configuration.injection
|
||||
|
||||
import eventDemo.business.command.GameCommandActionRunner
|
||||
import eventDemo.business.command.GameCommandHandler
|
||||
import eventDemo.business.event.GameEventHandler
|
||||
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
|
||||
import eventDemo.business.event.projection.projectionListener.ReactionListener
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
|
||||
fun Module.configureDIBusiness() {
|
||||
single {
|
||||
GameCommandHandler(get(), get(), get(), get())
|
||||
}
|
||||
singleOf(::GameEventHandler)
|
||||
singleOf(::GameCommandActionRunner)
|
||||
singleOf(::PlayerNotificationListener)
|
||||
singleOf(::ReactionListener)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package eventDemo.configuration.injection
|
||||
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import eventDemo.adapter.infrastructureLayer.event.GameEventBusInRabbinMQ
|
||||
import eventDemo.adapter.infrastructureLayer.event.GameEventStoreInPostgresql
|
||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInRedis
|
||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameProjectionBusInRabbitMQ
|
||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInRedis
|
||||
import eventDemo.business.event.GameEventBus
|
||||
import eventDemo.business.event.GameEventStore
|
||||
import eventDemo.business.event.projection.GameListRepository
|
||||
import eventDemo.business.event.projection.GameProjectionBus
|
||||
import eventDemo.business.event.projection.GameStateRepository
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.core.scope.ScopeCallback
|
||||
import org.koin.dsl.bind
|
||||
import redis.clients.jedis.JedisPooled
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
import javax.sql.DataSource
|
||||
|
||||
fun Module.configureDIInfrastructure(config: Configuration) {
|
||||
// Postgresql config
|
||||
single {
|
||||
JedisPooled(config.redisUrl)
|
||||
} bind UnifiedJedis::class
|
||||
|
||||
single {
|
||||
HikariConfig()
|
||||
.apply {
|
||||
jdbcUrl = config.postgresql.url
|
||||
username = config.postgresql.username
|
||||
password = config.postgresql.password
|
||||
maximumPoolSize = 10
|
||||
minimumIdle = 10
|
||||
}.let {
|
||||
HikariDataSource(it)
|
||||
}.also { datasource ->
|
||||
registerCallback(
|
||||
object : ScopeCallback {
|
||||
override fun onScopeClose(scope: Scope) {
|
||||
datasource.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} bind DataSource::class
|
||||
|
||||
// RabbitMQ config
|
||||
factory {
|
||||
ConnectionFactory().apply {
|
||||
host = config.rabbitmq.url
|
||||
port = config.rabbitmq.port
|
||||
username = config.rabbitmq.username
|
||||
password = config.rabbitmq.password
|
||||
}
|
||||
}
|
||||
|
||||
singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
|
||||
singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
|
||||
singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class
|
||||
|
||||
single {
|
||||
GameStateRepositoryInRedis(get())
|
||||
} bind GameStateRepository::class
|
||||
|
||||
single {
|
||||
GameListRepositoryInRedis(get())
|
||||
} bind GameListRepository::class
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package eventDemo.configuration.injection
|
||||
|
||||
import eventDemo.libs.event.VersionBuilder
|
||||
import eventDemo.libs.event.VersionBuilderLocal
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
|
||||
fun Module.configureDILibs() {
|
||||
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
|
||||
}
|
||||
+23
-27
@@ -1,21 +1,24 @@
|
||||
package eventDemo.contexts.auth.infrastructure.configure
|
||||
package eventDemo.configuration.ktor
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import eventDemo.configuration.configuration
|
||||
import eventDemo.contexts.auth.domain.User
|
||||
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
|
||||
import eventDemo.sharedKernel.UserId
|
||||
import eventDemo.business.entity.Player
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.auth.authentication
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.jwt.jwt
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.Date
|
||||
|
||||
fun Application.configureKtorAuth() {
|
||||
val jwtSecret = environment.config.configuration.jwtSecret
|
||||
private const val JWT_ISSUER = "PlayCardGame"
|
||||
|
||||
fun Application.configureSecurity() {
|
||||
val jwtSecret = environment.config.propertyOrNull("jwt.secret")?.getString() ?: error("You must set a jwt secret")
|
||||
|
||||
authentication {
|
||||
jwt {
|
||||
realm = "Play card game"
|
||||
@@ -26,11 +29,7 @@ fun Application.configureKtorAuth() {
|
||||
.build(),
|
||||
)
|
||||
validate { credential ->
|
||||
if (credential.payload
|
||||
.getClaim("username")
|
||||
.asString()
|
||||
.isNotEmpty()
|
||||
) {
|
||||
if (credential.payload.getClaim("username").asString() != "") {
|
||||
JWTPrincipal(credential.payload)
|
||||
} else {
|
||||
null
|
||||
@@ -41,25 +40,22 @@ fun Application.configureKtorAuth() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
routing {
|
||||
post("login/{username}") {
|
||||
val username = call.parameters["username"]!!
|
||||
val player = Player(name = username)
|
||||
|
||||
call.respond(hashMapOf("token" to player.makeJwt(jwtSecret)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val JWT_ISSUER = "PlayCardGame"
|
||||
|
||||
fun UserProjection.makeJwt(jwtSecret: String): String =
|
||||
makeJwt(jwtSecret, id, username)
|
||||
|
||||
fun User.makeJwt(jwtSecret: String): String =
|
||||
makeJwt(jwtSecret, id, username)
|
||||
|
||||
fun makeJwt(
|
||||
jwtSecret: String,
|
||||
id: UserId,
|
||||
username: String,
|
||||
): String =
|
||||
fun Player.makeJwt(jwtSecret: String): String =
|
||||
JWT
|
||||
.create()
|
||||
.withIssuer(JWT_ISSUER)
|
||||
.withClaim("username", username)
|
||||
.withClaim("userid", id.toString())
|
||||
.withClaim("username", name)
|
||||
.withPayload(Json.encodeToString(this))
|
||||
.withExpiresAt(Date(System.currentTimeMillis() + 60000))
|
||||
.sign(Algorithm.HMAC256(jwtSecret))
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eventDemo.contexts.game.infrastructure.configuration.ktor
|
||||
package eventDemo.configuration.ktor
|
||||
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
@@ -0,0 +1,42 @@
|
||||
package eventDemo.configuration.ktor
|
||||
|
||||
import eventDemo.configuration.injection.Configuration
|
||||
import eventDemo.configuration.injection.appKoinModule
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
import org.koin.ktor.plugin.Koin
|
||||
import org.koin.logger.slf4jLogger
|
||||
|
||||
fun Application.configureKoin() {
|
||||
install(Koin) {
|
||||
slf4jLogger()
|
||||
|
||||
modules(
|
||||
appKoinModule(
|
||||
environment.config.configuration(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ApplicationConfig.configuration() =
|
||||
Configuration(
|
||||
redisUrl = getProperty("redis.url"),
|
||||
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")
|
||||
+7
-10
@@ -1,14 +1,12 @@
|
||||
package eventDemo.contexts.game.infrastructure.configuration.ktor
|
||||
package eventDemo.configuration.ktor
|
||||
|
||||
import eventDemo.contexts.game.domain.game.GameId
|
||||
import eventDemo.contexts.game.domain.game.Player
|
||||
import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer
|
||||
import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer
|
||||
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||
import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
|
||||
import eventDemo.business.entity.GameId
|
||||
import eventDemo.business.entity.Player
|
||||
import eventDemo.configuration.serializer.CommandIdSerializer
|
||||
import eventDemo.configuration.serializer.GameIdSerializer
|
||||
import eventDemo.configuration.serializer.PlayerIdSerializer
|
||||
import eventDemo.configuration.serializer.UUIDSerializer
|
||||
import eventDemo.libs.command.CommandId
|
||||
import eventDemo.libs.eventSource.EventId
|
||||
import eventDemo.libs.serializer.UUIDSerializer
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
@@ -31,7 +29,6 @@ fun defaultJsonSerializer(): Json =
|
||||
SerializersModule {
|
||||
contextual(UUID::class) { UUIDSerializer }
|
||||
contextual(GameId::class) { GameIdSerializer }
|
||||
contextual(EventId::class) { EventIdSerializer }
|
||||
contextual(CommandId::class) { CommandIdSerializer }
|
||||
contextual(Player.PlayerId::class) { PlayerIdSerializer }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eventDemo.contexts.game.infrastructure.configuration.ktor
|
||||
package eventDemo.configuration.ktor
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user