diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 0171fb6..760692d 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -19,6 +19,7 @@
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 808898e..e9d9c7c 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/add-a-new-microservice-ethereal-marshmallow.md b/add-a-new-microservice-ethereal-marshmallow.md
new file mode 100644
index 0000000..7ff5c61
--- /dev/null
+++ b/add-a-new-microservice-ethereal-marshmallow.md
@@ -0,0 +1,334 @@
+# Plan: Add Coordinator Microservice
+
+## Context
+NowChess scales `core` horizontally via shared Redis but lacks:
+- **Instance visibility**: no way to list running cores or their load
+- **Load balancing**: games land randomly on cores; no rebalancing
+- **Failover**: dead cores orphan subscriptions; bullet chess requires <1s recovery
+- **Auto-scaling**: manual ops to add/remove cores
+- **Cache management**: no eviction of stale games from core memory
+
+Bullet chess games run on move timings of <3s. 30s failover = game lost on clock. Target: **<300ms failover**.
+
+---
+
+## Architecture: Sub-1s Failover
+
+### Why Not Polling/TTL
+- TTL expiry: minimum 10-30s detection
+- HTTP polling 3x failure: 30s minimum
+- **gRPC streaming TCP drop: 50-200ms** — use this as primary
+
+### Primary: gRPC Bidirectional Streaming
+- Core opens a **persistent bidirectional stream** (`CoreHeartbeatStream`) to coordinator on startup
+- Core sends heartbeat frames every **200ms**
+- Core crash = TCP RST/FIN → coordinator stream error in **~50-200ms**
+- Stream also carries metadata updates (subscription count changes) in real-time
+
+### Fallback: Redis Heartbeat + K8s Watch
+- Redis heartbeat key `{prefix}:instances:{instanceId}` with **5s TTL**, refreshed every **2s**
+- K8s pod watch via Kubernetes Java client (event-driven; handles pod eviction/OOMKill)
+- Fallback covers: network partition (TCP stays up but core is zombie), coordinator restart gap
+
+---
+
+## Design
+
+### 1. Module: `modules/coordinator`
+**Language**: Scala 3.5.1, Quarkus REST + gRPC
+**Ports**: HTTP 8086, gRPC 9086
+**Dependencies**: Redisson, Kubernetes Java client, Quarkus gRPC
+**Persistence**: None (all state in Redis)
+
+---
+
+### 2. Instance Registry
+
+**Redis schema**:
+```
+{prefix}:instances:{instanceId}
+ - TTL: 5s (refreshed by core every 2s via background task)
+ - Value: JSON
+ {
+ "instanceId": "core-abc123",
+ "hostname": "core-pod-3",
+ "httpPort": 8080,
+ "grpcPort": 9080,
+ "subscriptionCount": 147,
+ "localCacheSize": 147,
+ "lastHeartbeat": "2026-04-26T10:15:30.123Z"
+ }
+
+{prefix}:instance:{instanceId}:games
+ - Type: Redis Set (no TTL — managed explicitly)
+ - Members: all gameIds currently subscribed on this instance
+```
+
+**Core changes** (new `InstanceHeartbeatService` bean in `modules/core`):
+- `@PostConstruct`: generate stable `instanceId` (hostname + random suffix); open gRPC stream to coordinator; publish Redis heartbeat; register in `{prefix}:instances:{instanceId}`
+- Every 200ms: send heartbeat frame on gRPC stream (carries `subscriptionCount`)
+- Every 2s: refresh Redis heartbeat bucket TTL
+- `subscribeGame(gameId)`: `SADD {prefix}:instance:{instanceId}:games gameId`
+- `unsubscribeGame(gameId)` / `evictGame(gameId)`: `SREM {prefix}:instance:{instanceId}:games gameId`
+- `@PreDestroy`: delete Redis key + games set; close gRPC stream (clean shutdown)
+
+---
+
+### 3. Health Monitoring (3 signals, primary fast)
+
+| Signal | Mechanism | Detection time | Role |
+|--------|-----------|---------------|------|
+| **gRPC stream drop** | TCP RST/FIN on bidirectional stream | 50–200ms | Primary |
+| **Redis heartbeat expiry** | `{prefix}:instances:{instanceId}` TTL=5s | 5–7s | Fallback |
+| **K8s pod watch** | `CoreV1Api.listNamespacedPod` watch stream | ~instant (pod events) | Fallback |
+
+**Dead decision**:
+- gRPC stream drops → **immediate failover** (no confirmation needed; games must recover fast)
+- Redis heartbeat expires (gRPC still up) → verify with single HTTP `/q/health` call → if fail: failover
+- K8s pod NotReady (gRPC still up) → failover
+
+---
+
+### 4. Failover Protocol (<300ms target)
+
+```
+T+0ms Core JVM crashes / network drops
+T+50ms Coordinator: gRPC stream error received
+T+52ms SMEMBERS {prefix}:instance:{instanceId}:games → list of orphaned gameIds
+T+55ms Distribute gameIds across healthy cores (least-loaded first)
+T+60ms BatchResubscribeGames gRPC call(s) fire to healthy core(s)
+T+150ms Healthy cores resubscribed; Redis s2c topics live again
+T+200ms WebSocket clients reconnect; receive GameFullEventDto on CONNECTED
+```
+
+**Failover steps** (coordinator `FailoverService`):
+1. On stream drop for `instanceId`:
+ a. Mark instance DEAD in local map
+ b. `SMEMBERS {prefix}:instance:{instanceId}:games`
+ c. Group gameIds into batches per target core (round-robin by load)
+ d. For each target core: call `BatchResubscribeGames(gameIds)`
+ e. Each target core: calls `subscribeGame(gameId)` for each (loads from Redis if not in local cache)
+ f. `DEL {prefix}:instance:{instanceId}:games` (cleanup)
+2. Log failover event with count of games migrated + latency
+
+---
+
+### 5. Load Rebalancing
+
+**Thresholds** (both must be evaluated):
+1. **Absolute**: any core > 500 games → rebalance
+2. **Relative**: max load > mean × 1.2 AND max - min > 50 games → rebalance
+
+**Algorithm** (runs every 30s, min 60s between actual rebalances):
+1. Read all `{prefix}:instances:*` keys → load map
+2. Identify overloaded cores (exceed either threshold)
+3. For each overloaded core: pick `excess = load - targetLoad` games
+4. Assign excess games to underloaded cores
+5. Call `UnsubscribeGames(gameIds)` on overloaded core
+6. Call `BatchResubscribeGames(gameIds)` on target core
+7. Overloaded core: `SREM` each game from its set
+8. Target core: `SADD` each game to its set on subscribe
+
+---
+
+### 6. Auto-Scaling
+
+**Metric**: avg `subscriptionCount` across all cores
+
+**Actions**:
+- avg > `scale-up-threshold` (80% of max): patch `nowchess-core` Argo Rollout `spec.replicas += 1`
+- avg < `scale-down-threshold` (30% of max) AND `replicas > min-replicas`: drain one core then scale down
+- Backoff: min 2-minute interval between scale events
+
+**Argo Rollouts API**:
+- CRD: `argoproj.io/v1alpha1`, Kind: `Rollout`, resource: `rollouts`
+- Scale via Fabric8 `GenericKubernetesResource` patch on `spec.replicas`
+- No StatefulSet — Argo Rollout owns pod lifecycle (canary/blue-green strategies respected)
+- Pod watch filter: label selector `app=nowchess-core` (Rollout sets this; `rollouts-pod-template-hash` is Argo's equivalent of `pod-template-hash`)
+
+**Drain before scale-down**:
+1. Pick least-loaded core
+2. Migrate all its games to other cores via `BatchResubscribeGames`
+3. Call `DrainInstance(instanceId)` on that core (sets it to reject new subscriptions)
+4. After drain confirmed: patch Rollout `spec.replicas -= 1`
+
+---
+
+### 7. Cache Eviction
+
+**Trigger**: coordinator scans `{prefix}:game:entry:*` every 10 minutes
+**Policy**: if `now - lastUpdated > 45min` AND `gameId` in any instance's games set → call `EvictGame`
+**Effect**: core removes game from `localEngines` and `unsubscribeGame`, `SREM` from instance set
+
+---
+
+### 8. Proto: `coordinator_service.proto`
+
+```proto
+syntax = "proto3";
+package de.nowchess.coordinator;
+
+service CoordinatorService {
+ // Core → Coordinator: bidirectional stream for liveness
+ rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
+
+ // Coordinator → Core: batch resubscribe after failover or rebalance
+ rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
+
+ // Coordinator → Core: unsubscribe games (rebalance source)
+ rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
+
+ // Coordinator → Core: evict idle games from local cache
+ rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
+
+ // Coordinator → Core: drain instance before scale-down
+ rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
+}
+
+message HeartbeatFrame {
+ string instanceId = 1;
+ string hostname = 2;
+ int32 httpPort = 3;
+ int32 grpcPort = 4;
+ int32 subscriptionCount = 5;
+ int32 localCacheSize = 6;
+ int64 timestampMillis = 7;
+}
+
+message CoordinatorCommand {
+ // Future: coordinator can push commands back (e.g., "start draining")
+ string type = 1;
+ string payload = 2;
+}
+
+message BatchResubscribeRequest {
+ repeated string gameIds = 1;
+}
+
+message BatchResubscribeResponse {
+ int32 subscribedCount = 1;
+ repeated string failedGameIds = 2;
+}
+
+message UnsubscribeGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message UnsubscribeGamesResponse {
+ int32 unsubscribedCount = 1;
+}
+
+message EvictGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message EvictGamesResponse {
+ int32 evictedCount = 1;
+}
+
+message DrainInstanceRequest {}
+
+message DrainInstanceResponse {
+ int32 gamesMigrated = 0;
+}
+```
+
+---
+
+### 9. Coordinator REST API (internal)
+
+- `GET /api/coordinator/instances` — all cores with load, health state
+- `GET /api/coordinator/metrics` — load distribution, rebalance history
+- `POST /api/coordinator/rebalance` — manual rebalance trigger
+- `POST /api/coordinator/failover/{instanceId}` — manual failover
+- `POST /api/coordinator/scale-up` / `scale-down` — manual scaling
+
+---
+
+### 10. Configuration
+
+**`modules/coordinator/src/main/resources/application.yml`**:
+```yaml
+quarkus.application.name: nowchess-coordinator
+quarkus.http.port: 8086
+quarkus.grpc.server.port: 9086
+
+nowchess.coordinator.max-games-per-core: 500
+nowchess.coordinator.max-deviation-percent: 20
+nowchess.coordinator.rebalance-interval: 30s
+nowchess.coordinator.rebalance-min-interval: 60s
+nowchess.coordinator.heartbeat-ttl: 5s
+nowchess.coordinator.stream-heartbeat-interval: 200ms
+nowchess.coordinator.cache-eviction-interval: 10m
+nowchess.coordinator.game-idle-threshold: 45m
+nowchess.coordinator.auto-scale-enabled: false
+nowchess.coordinator.scale-up-threshold: 0.8
+nowchess.coordinator.scale-down-threshold: 0.3
+nowchess.coordinator.scale-min-replicas: 2
+nowchess.coordinator.scale-max-replicas: 10
+nowchess.coordinator.k8s-namespace: default
+nowchess.coordinator.k8s-rollout-name: nowchess-core
+nowchess.coordinator.k8s-rollout-label-selector: app=nowchess-core
+
+quarkus.kubernetes-client.trust-certs: true
+```
+
+**Core `application.yml` additions**:
+```yaml
+nowchess.coordinator.host: localhost
+nowchess.coordinator.grpc-port: 9086
+nowchess.coordinator.stream-heartbeat-interval: 200ms
+nowchess.coordinator.redis-heartbeat-interval: 2s
+nowchess.coordinator.instance-id: ${HOSTNAME:local}-${quarkus.uuid}
+```
+
+---
+
+### 11. Files to Create / Modify
+
+**New — `modules/coordinator/`**:
+```
+build.gradle.kts
+src/main/proto/coordinator_service.proto
+src/main/resources/application.yml
+src/main/scala/de/nowchess/coordinator/
+ resource/CoordinatorResource.scala # REST endpoints
+ service/InstanceRegistry.scala # Redis instance list + in-memory map
+ service/HealthMonitor.scala # gRPC stream watcher + Redis TTL + k8s watch
+ service/FailoverService.scala # dead core → BatchResubscribe
+ service/LoadBalancer.scala # rebalance logic
+ service/AutoScaler.scala # k8s StatefulSet scaling
+ service/CacheEvictionManager.scala # idle game eviction
+ grpc/CoordinatorGrpcServer.scala # CoordinatorService gRPC impl (for HeartbeatStream)
+```
+
+**Modify — `modules/core/`**:
+- `build.gradle.kts` — add `coordinator_service.proto` stub, keep grpc dep
+- `src/main/proto/coordinator_service.proto` — copy (or symlink) proto for stub generation
+- `src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala` — `SADD`/`SREM` on subscribe/unsubscribe + implement `BatchResubscribeGames`, `UnsubscribeGames`, `EvictGames`, `DrainInstance` gRPC handlers
+- `src/main/scala/de/nowchess/chess/` — new `InstanceHeartbeatService.scala` (startup, gRPC stream, Redis TTL refresh)
+- `src/main/resources/application.yml` — coordinator connection config
+
+**Modify — root**:
+- `settings.gradle.kts` — add `include("modules/coordinator")`
+
+---
+
+## Verification
+
+1. `./compile` — coordinator and core compile cleanly
+2. **Stream detection**: start core + coordinator; kill core JVM (`kill -9`); coordinator logs failover within 300ms
+3. **Game continuity**: active game on killed core; WebSocket client reconnects and receives game state
+4. **Rebalance**: create 600 games on core-1 (2-core setup); coordinator rebalances ~100 to core-2
+5. **Fallback**: disconnect gRPC stream manually but keep core alive; Redis TTL fallback triggers within 7s
+6. **Cache eviction**: create idle game; coordinator calls `EvictGames` after 45min idle
+7. **REST metrics**: `curl localhost:8086/api/coordinator/metrics` returns per-core load + health
+8. **Restart recovery**: restart coordinator; gRPC streams re-establish from cores; state rebuilt from Redis
+
+---
+
+## Dependencies (new)
+
+- `io.fabric8:kubernetes-client:6.13.0` (Fabric8 k8s client — handles Argo `Rollout` CRD via `GenericKubernetesResource`; no Argo Java SDK needed)
+- Redisson — already in core, reuse via shared config
+- Quarkus gRPC — already in core, reuse
diff --git a/modules/account/build.gradle.kts b/modules/account/build.gradle.kts
index 52ba1f2..8c350d7 100644
--- a/modules/account/build.gradle.kts
+++ b/modules/account/build.gradle.kts
@@ -45,6 +45,8 @@ dependencies {
}
}
+ implementation(project(":modules:security"))
+
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson")
@@ -60,6 +62,7 @@ dependencies {
implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
diff --git a/modules/account/src/main/resources/application.yml b/modules/account/src/main/resources/application.yml
index c63e973..b15eadd 100644
--- a/modules/account/src/main/resources/application.yml
+++ b/modules/account/src/main/resources/application.yml
@@ -6,8 +6,6 @@ quarkus:
rest-client:
core-service:
url: http://localhost:8080
- bot-platform-service:
- url: http://localhost:8087
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
@@ -24,13 +22,24 @@ quarkus:
schema-management:
strategy: drop-and-create
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+ internal:
+ secret: ${INTERNAL_SECRET}
+
"%deployed":
quarkus:
rest-client:
core-service:
url: ${CORE_SERVICE_URL}
- bot-platform-service:
- url: ${BOT_PLATFORM_SERVICE_URL}
+ nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
datasource:
db-kind: postgresql
username: ${DB_USER}
diff --git a/modules/account/src/main/scala/de/nowchess/account/client/BotPlatformClient.scala b/modules/account/src/main/scala/de/nowchess/account/client/BotPlatformClient.scala
deleted file mode 100644
index 2d0faad..0000000
--- a/modules/account/src/main/scala/de/nowchess/account/client/BotPlatformClient.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package de.nowchess.account.client
-
-import jakarta.ws.rs.*
-import jakarta.ws.rs.core.MediaType
-import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
-
-@Path("/api/bot")
-@RegisterRestClient(configKey = "bot-platform-service")
-trait BotPlatformClient:
-
- @POST
- @Path("/game/{gameId}/assign")
- @Produces(Array(MediaType.APPLICATION_JSON))
- def assignBot(
- @PathParam("gameId") gameId: String,
- @QueryParam("botId") botId: String,
- @QueryParam("difficulty") difficulty: Int,
- @QueryParam("playingAs") playingAs: String,
- @QueryParam("botAccountId") botAccountId: String,
- ): Unit
diff --git a/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala b/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala
index 4f06560..648b83b 100644
--- a/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala
@@ -1,7 +1,9 @@
package de.nowchess.account.client
+import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CorePlayerInfo(id: String, displayName: String)
@@ -16,6 +18,7 @@ case class CoreGameResponse(gameId: String)
@Path("/api/board/game")
@RegisterRestClient(configKey = "core-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
trait CoreGameClient:
@POST
diff --git a/modules/account/src/main/scala/de/nowchess/account/config/RedisConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/RedisConfig.scala
new file mode 100644
index 0000000..a85ad83
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/config/RedisConfig.scala
@@ -0,0 +1,18 @@
+package de.nowchess.account.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedisConfig:
+ // scalafix:off DisableSyntax.var
+ @ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
+ var host: String = uninitialized
+
+ @ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
+ var port: Int = uninitialized
+
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/account/src/main/scala/de/nowchess/account/config/RedissonProducer.scala b/modules/account/src/main/scala/de/nowchess/account/config/RedissonProducer.scala
new file mode 100644
index 0000000..38fb951
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/config/RedissonProducer.scala
@@ -0,0 +1,33 @@
+package de.nowchess.account.config
+
+import jakarta.annotation.PreDestroy
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Produces
+import jakarta.inject.Inject
+import org.redisson.Redisson
+import org.redisson.api.RedissonClient
+import org.redisson.config.Config
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedissonProducer:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redisConfig: RedisConfig = uninitialized
+ private var clientOpt: Option[RedissonClient] = None
+ // scalafix:on DisableSyntax.var
+
+ @Produces
+ @ApplicationScoped
+ def produceRedissonClient(): RedissonClient =
+ val config = new Config()
+ config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
+ config.useSingleServer().setConnectionMinimumIdleSize(1)
+ config.useSingleServer().setConnectTimeout(500)
+ val client = Redisson.create(config)
+ clientOpt = Some(client)
+ client
+
+ @PreDestroy
+ def shutdown(): Unit =
+ clientOpt.foreach(_.shutdown())
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala
index 9a28cb5..6d77005 100644
--- a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala
@@ -47,6 +47,14 @@ class ChallengeResource:
val userId = UUID.fromString(jwt.getSubject)
Response.ok(challengeService.listForUser(userId)).build()
+ @GET
+ @Path("/{id}")
+ def get(@PathParam("id") id: UUID): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ challengeService.findById(id, userId) match
+ case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
+ case Left(error) => errorResponse(error)
+
@POST
@Path("/{id}/accept")
def accept(@PathParam("id") id: UUID): Response =
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
index ebc3346..863f596 100644
--- a/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
@@ -1,8 +1,8 @@
package de.nowchess.account.resource
-import de.nowchess.account.client.{BotPlatformClient, CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
+import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
-import de.nowchess.account.service.AccountService
+import de.nowchess.account.service.{AccountService, BotEventPublisher}
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -23,19 +23,13 @@ import java.util.UUID
class OfficialChallengeResource:
// scalafix:off DisableSyntax.var
- @Inject
- var accountService: AccountService = uninitialized
-
- @Inject
- var jwt: JsonWebToken = uninitialized
+ @Inject var accountService: AccountService = uninitialized
+ @Inject var jwt: JsonWebToken = uninitialized
+ @Inject var botEventPublisher: BotEventPublisher = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
-
- @Inject
- @RestClient
- var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
@@ -43,9 +37,9 @@ class OfficialChallengeResource:
@POST
@Path("/{botName}")
def challengeWithDifficulty(
- @PathParam("botName") botName: String,
- @QueryParam("difficulty") difficulty: Int,
- @QueryParam("color") color: String,
+ @PathParam("botName") botName: String,
+ @QueryParam("difficulty") difficulty: Int,
+ @QueryParam("color") color: String,
): Response =
if difficulty < 1000 || difficulty > 2800 then
return Response
@@ -56,10 +50,12 @@ class OfficialChallengeResource:
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
case other =>
- return Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto(s"Invalid color: $other. Must be white, black or random")).build()
+ return Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
+ .build()
val userId = UUID.fromString(jwt.getSubject)
-
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
val userOpt = accountService.findById(userId)
@@ -70,9 +66,9 @@ class OfficialChallengeResource:
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
case (Some(bot), Some(user)) =>
val userIsWhite = playerColor match
- case "white" => true
- case "black" => false
- case _ => scala.util.Random.nextBoolean()
+ case "white" => true
+ case "black" => false
+ case _ => scala.util.Random.nextBoolean()
val (white, black, botColor) =
if userIsWhite then
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
@@ -86,9 +82,6 @@ class OfficialChallengeResource:
case Left(err) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
case Right(id) =>
- try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString)
- catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id)
- Response
- .status(Response.Status.CREATED)
- .entity(OfficialChallengeResponse(id, botName, difficulty))
- .build()
+ try botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
+ Response.status(Response.Status.CREATED).entity(OfficialChallengeResponse(id, botName, difficulty)).build()
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/BotEventPublisher.scala b/modules/account/src/main/scala/de/nowchess/account/service/BotEventPublisher.scala
new file mode 100644
index 0000000..6e299b8
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/service/BotEventPublisher.scala
@@ -0,0 +1,30 @@
+package de.nowchess.account.service
+
+import de.nowchess.account.config.RedisConfig
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import org.redisson.api.RedissonClient
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class BotEventPublisher:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redisson: RedissonClient = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
+ val event = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
+ redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
+ ()
+
+ def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
+ val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
+ redisson.getTopic(s"${redisConfig.prefix}:user:$destUserId:events").publish(event)
+ ()
+
+ def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
+ val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
+ redisson.getTopic(s"${redisConfig.prefix}:user:$challengerId:events").publish(event)
+ ()
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
index 57e0774..f7b825c 100644
--- a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
@@ -1,7 +1,6 @@
package de.nowchess.account.service
import de.nowchess.account.client.{
- BotPlatformClient,
CoreCreateGameRequest,
CoreGameClient,
CoreGameResponse,
@@ -26,6 +25,7 @@ import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
+
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.UUID
@@ -47,8 +47,7 @@ class ChallengeService:
var coreGameClient: CoreGameClient = uninitialized
@Inject
- @RestClient
- var botPlatformClient: BotPlatformClient = uninitialized
+ var botEventPublisher: BotEventPublisher = uninitialized
// scalafix:on
@Transactional
@@ -75,6 +74,8 @@ class ChallengeService:
challenge.createdAt = Instant.now()
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
challengeRepository.persist(challenge)
+ try botEventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
challenge
@Transactional
@@ -89,6 +90,8 @@ class ChallengeService:
challenge.gameId = gameId
challengeRepository.merge(challenge)
notifyBotIfNeeded(challenge, gameId)
+ try botEventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
challenge
@Transactional
@@ -115,6 +118,16 @@ class ChallengeService:
challengeRepository.merge(challenge)
challenge
+ def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
+ for
+ challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
+ _ <- Either.cond(
+ challenge.challenger.id == userId || challenge.destUser.id == userId,
+ (),
+ ChallengeError.NotAuthorized,
+ )
+ yield challenge
+
def listForUser(userId: UUID): ChallengeListDto =
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
@@ -125,8 +138,8 @@ class ChallengeService:
List(challenge.challenger, challenge.destUser).foreach { user =>
user.getBotAccounts.headOption.foreach { bot =>
val playingAs = if white.id == user.id.toString then "white" else "black"
- try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString)
- catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId)
+ try botEventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
}
}
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
index ee00f74..0abf842 100644
--- a/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
@@ -1,3 +1,5 @@
package de.nowchess.api.dto
-final case class PlayerInfoDto(id: String, displayName: String)
+import de.nowchess.api.player.PlayerType
+
+final case class PlayerInfoDto(id: String, displayName: String, playerType: PlayerType)
diff --git a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
index c6b8053..44de279 100644
--- a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
@@ -23,4 +23,10 @@ object PlayerId:
final case class PlayerInfo(
id: PlayerId,
displayName: String,
+ playerType: PlayerType = PlayerType.Human,
)
+
+enum PlayerType:
+ case Human
+ case OfficialBot
+ case Bot
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotGameInfo.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotGameInfo.scala
deleted file mode 100644
index 609032e..0000000
--- a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotGameInfo.scala
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.nowchess.botplatform.registry
-
-case class BotGameInfo(
- botId: String,
- difficulty: Int,
- playingAs: String,
- botAccountId: String,
-)
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
index 9a35c24..2f084f7 100644
--- a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
@@ -1,29 +1,44 @@
package de.nowchess.botplatform.registry
+import de.nowchess.botplatform.config.RedisConfig
import io.smallrye.mutiny.subscription.MultiEmitter
import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import org.redisson.api.RedissonClient
+import org.redisson.api.listener.MessageListener
+import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class BotRegistry:
- private val connections = ConcurrentHashMap[String, MultiEmitter[?]]()
+ // scalafix:off DisableSyntax.var
+ @Inject var redisson: RedissonClient = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val connections = ConcurrentHashMap[String, (MultiEmitter[?], Int)]()
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
- connections.put(botId, emitter)
+ val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events")
+ val listenerId = topic.addListener(
+ classOf[String],
+ new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ emitter.asInstanceOf[MultiEmitter[String]].emit(msg),
+ )
+ connections.put(botId, (emitter, listenerId))
()
def unregister(botId: String): Unit =
- connections.remove(botId)
- ()
+ Option(connections.remove(botId)).foreach { (_, listenerId) =>
+ redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").removeListener(listenerId)
+ }
- def dispatch(botId: String, event: String): Boolean =
- Option(connections.get(botId)) match
- case Some(emitter) =>
- emitter.asInstanceOf[MultiEmitter[String]].emit(event)
- true
- case None => false
+ def dispatch(botId: String, event: String): Unit =
+ redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
+ ()
def registeredBots: List[String] =
import scala.jdk.CollectionConverters.*
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
index aa45935..0e4406f 100644
--- a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
@@ -1,7 +1,7 @@
package de.nowchess.botplatform.resource
-import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry}
-import de.nowchess.botplatform.service.GameBotMonitor
+import de.nowchess.botplatform.config.RedisConfig
+import de.nowchess.botplatform.registry.BotRegistry
import io.smallrye.mutiny.Multi
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
@@ -9,6 +9,8 @@ import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
+import org.redisson.api.RedissonClient
+import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
@Path("/api/bot")
@@ -17,14 +19,10 @@ import scala.compiletime.uninitialized
class BotEventResource:
// scalafix:off DisableSyntax.var
- @Inject
- var registry: BotRegistry = uninitialized
-
- @Inject
- var jwt: JsonWebToken = uninitialized
-
- @Inject
- var gameMonitor: GameBotMonitor = uninitialized
+ @Inject var registry: BotRegistry = uninitialized
+ @Inject var jwt: JsonWebToken = uninitialized
+ @Inject var redisson: RedissonClient = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
@GET
@@ -34,7 +32,7 @@ class BotEventResource:
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
val subject = Option(jwt.getSubject).getOrElse("")
if tokenType != "bot" || subject != botId then
- Multi.createFrom().failure(new jakarta.ws.rs.ForbiddenException("Not authorized for this bot"))
+ Multi.createFrom().failure(new ForbiddenException("Not authorized for this bot"))
else
Multi.createFrom().emitter[String] { emitter =>
registry.register(botId, emitter)
@@ -46,22 +44,24 @@ class BotEventResource:
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
Multi.createFrom().emitter[String] { emitter =>
- registry.register(s"game-$gameId", emitter)
- emitter.onTermination(() => registry.unregister(s"game-$gameId"))
+ val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
+ val topic = redisson.getTopic(topicName)
+ val listenerId = topic.addListener(
+ classOf[String],
+ new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit = emitter.emit(msg),
+ )
+ emitter.onTermination(() => topic.removeListener(listenerId))
}
@POST
- @Path("/game/{gameId}/assign")
+ @Path("/game/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
- def assignBot(
- @PathParam("gameId") gameId: String,
- @QueryParam("botId") botId: String,
- @QueryParam("difficulty") difficulty: Int,
- @QueryParam("playingAs") playingAs: String,
- @QueryParam("botAccountId") botAccountId: String,
+ def makeMove(
+ @PathParam("gameId") gameId: String,
+ @PathParam("uci") uci: String,
): Response =
- val info = BotGameInfo(botId, difficulty, playingAs, botAccountId)
- gameMonitor.watchGame(gameId, info)
- val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}"""
- registry.dispatch(botId, event)
+ val playerId = Option(jwt.getSubject).getOrElse("")
+ val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
+ redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:c2s").publish(moveMsg)
Response.ok().build()
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/service/GameBotMonitor.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/service/GameBotMonitor.scala
deleted file mode 100644
index 321f79a..0000000
--- a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/service/GameBotMonitor.scala
+++ /dev/null
@@ -1,56 +0,0 @@
-package de.nowchess.botplatform.service
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import de.nowchess.botplatform.config.RedisConfig
-import de.nowchess.botplatform.registry.BotGameInfo
-import jakarta.enterprise.context.ApplicationScoped
-import jakarta.inject.Inject
-import org.redisson.api.{RedissonClient, RBlockingQueue}
-import org.redisson.api.listener.MessageListener
-import scala.compiletime.uninitialized
-import java.util.concurrent.ConcurrentHashMap
-
-@ApplicationScoped
-class GameBotMonitor:
-
- // scalafix:off DisableSyntax.var
- @Inject var redisson: RedissonClient = uninitialized
- @Inject var redisConfig: RedisConfig = uninitialized
- @Inject var objectMapper: ObjectMapper = uninitialized
- // scalafix:on DisableSyntax.var
-
- private val listeners = ConcurrentHashMap[String, Int]()
-
- def watchGame(gameId: String, info: BotGameInfo): Unit =
- val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
- val topic = redisson.getTopic(topicName)
- val listenerId = topic.addListener(
- classOf[String],
- new MessageListener[String]:
- def onMessage(channel: CharSequence, msg: String): Unit =
- handleS2cEvent(gameId, msg, info),
- )
- listeners.put(gameId, listenerId)
-
- def unwatchGame(gameId: String): Unit =
- Option(listeners.remove(gameId)).foreach { listenerId =>
- val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
- redisson.getTopic(topicName).removeListener(listenerId)
- }
-
- private val terminalStatuses = Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
-
- private def handleS2cEvent(gameId: String, msg: String, info: BotGameInfo): Unit =
- try
- val node = objectMapper.readTree(msg)
- val status = Option(node.path("state").path("status").asText()).getOrElse("")
- if terminalStatuses.contains(status) then
- unwatchGame(gameId)
- else
- val turn = Option(node.path("state").path("turn").asText()).getOrElse("")
- if turn == info.playingAs then
- val fen = node.path("state").path("fen").asText()
- val req = s"""{"gameId":"$gameId","fen":"${fen.replace("\"", "\\\"")}","turn":"$turn","playingAs":"${info.playingAs}","difficulty":${info.difficulty},"botAccountId":"${info.botAccountId}"}"""
- val queue: RBlockingQueue[String] = redisson.getBlockingQueue("nowchess:bot:move-queue")
- queue.put(req)
- catch case _: Exception => ()
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index c07c28d..f870d1a 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -52,6 +52,7 @@ dependencies {
implementation(project(":modules:rule"))
implementation(project(":modules:io"))
implementation(project(":modules:official-bots"))
+ implementation(project(":modules:security"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml
index 75ada85..265ad97 100644
--- a/modules/core/src/main/resources/application.yml
+++ b/modules/core/src/main/resources/application.yml
@@ -23,6 +23,9 @@ nowchess:
port: 6379
prefix: nowchess
+ internal:
+ secret: ${INTERNAL_SECRET}
+
coordinator:
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
host: localhost
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
index bf32d22..45f2ce3 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
@@ -2,14 +2,17 @@ package de.nowchess.chess.client
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
import de.nowchess.api.game.GameContext
+import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CombinedExportResponse(fen: String, pgn: String)
@Path("/io")
@RegisterRestClient(configKey = "io-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
trait IoServiceClient:
@POST
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala
index 1f39fdb..7c6c425 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala
@@ -3,8 +3,10 @@ package de.nowchess.chess.client
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.PostMoveStatus
+import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class RuleSquareRequest(context: GameContext, square: String)
@@ -12,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
@Path("/api/rules")
@RegisterRestClient(configKey = "rule-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
trait RuleServiceClient:
@POST
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala
index 8ee740b..fca9aa9 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala
@@ -1,10 +1,13 @@
package de.nowchess.chess.client
+import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@RegisterRestClient(configKey = "store-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
@Path("/game")
trait StoreServiceClient:
@GET
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala
index 691e488..381a12e 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.GameStateEventDto
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
+import de.nowchess.api.board.Color
import de.nowchess.chess.observer.{GameEvent, Observer}
import de.nowchess.chess.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper
@@ -54,6 +56,21 @@ class GameRedisPublisher(
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
+ result = entry.engine.context.result.map {
+ case GameResult.Win(Color.White, _) => "white"
+ case GameResult.Win(Color.Black, _) => "black"
+ case GameResult.Draw(_) => "draw"
+ },
+ terminationReason = entry.engine.context.result.map {
+ case GameResult.Win(_, WinReason.Checkmate) => "checkmate"
+ case GameResult.Win(_, WinReason.Resignation) => "resignation"
+ case GameResult.Win(_, WinReason.TimeControl) => "timeout"
+ case GameResult.Draw(DrawReason.Stalemate) => "stalemate"
+ case GameResult.Draw(DrawReason.InsufficientMaterial) => "insufficient_material"
+ case GameResult.Draw(DrawReason.FiftyMoveRule) => "fifty_move"
+ case GameResult.Draw(DrawReason.ThreefoldRepetition) => "repetition"
+ case GameResult.Draw(DrawReason.Agreement) => "agreement"
+ },
redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/GameWritebackEventDto.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/GameWritebackEventDto.scala
index 2a06037..f00214f 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/redis/GameWritebackEventDto.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameWritebackEventDto.scala
@@ -21,6 +21,8 @@ case class GameWritebackEventDto(
clockMoveDeadline: Option[Long],
clockActiveColor: Option[String],
pendingDrawOffer: Option[String],
+ result: Option[String] = None,
+ terminationReason: Option[String] = None,
redoStack: List[String] = Nil,
pendingTakebackRequest: Option[String] = None,
)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala
index 424b072..24ad2cd 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala
@@ -38,7 +38,7 @@ object GameDtoMapper:
case _ => base
def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
- PlayerInfoDto(info.id.value, info.displayName)
+ PlayerInfoDto(info.id.value, info.displayName, info.playerType)
def toClockDto(entry: GameEntry): Option[ClockDto] =
val now = Instant.now()
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
index 901f4c3..1d9aead 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
@@ -24,6 +24,7 @@ import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.chess.observer.*
import de.nowchess.chess.redis.GameRedisSubscriberManager
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
+import de.nowchess.security.InternalOnly
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
@@ -79,9 +80,9 @@ class GameResource:
val color = colorOf(entry)
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
- private def assertIsBot(): Unit =
+ private def assertIsNotBot(): Unit =
val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
- if !Set("bot", "official-bot").contains(botType) then
+ if Set("bot", "official-bot").contains(botType) then
throw ForbiddenException("Only bots can make moves")
// scalafix:on DisableSyntax.throw
@@ -153,6 +154,7 @@ class GameResource:
// scalafix:off DisableSyntax.throw
@POST
+ @InternalOnly
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(body: CreateGameRequestDto): Response =
@@ -189,7 +191,7 @@ class GameResource:
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
- assertIsBot()
+ assertIsNotBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
assertIsCurrentPlayer(entry)
diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts
index 1f5bb0f..35b467f 100644
--- a/modules/io/build.gradle.kts
+++ b/modules/io/build.gradle.kts
@@ -52,6 +52,7 @@ dependencies {
implementation(project(":modules:api"))
implementation(project(":modules:json"))
implementation(project(":modules:rule"))
+ implementation(project(":modules:security"))
// Jackson for JSON serialization/deserialization
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
diff --git a/modules/io/src/main/resources/application.yml b/modules/io/src/main/resources/application.yml
index 9039794..89f8f02 100644
--- a/modules/io/src/main/resources/application.yml
+++ b/modules/io/src/main/resources/application.yml
@@ -6,6 +6,10 @@ quarkus:
use-separate-server: false
application:
name: nowchess-io
+
+nowchess:
+ internal:
+ secret: ${INTERNAL_SECRET}
smallrye-openapi:
info-title: NowChess IO Service
info-version: 1.0.0
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
index bc653bd..f959b7b 100644
--- a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
@@ -1,6 +1,7 @@
package de.nowchess.io.service.resource
import de.nowchess.api.game.GameContext
+import de.nowchess.security.InternalOnly
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
@@ -15,6 +16,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag
@Path("/io")
@ApplicationScoped
+@InternalOnly
@Tag(name = "IO", description = "Chess notation import and export")
class IoResource:
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/MoveRequestParser.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/MoveRequestParser.scala
deleted file mode 100644
index b179004..0000000
--- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/MoveRequestParser.scala
+++ /dev/null
@@ -1,26 +0,0 @@
-package de.nowchess.bot.service
-
-import com.fasterxml.jackson.databind.ObjectMapper
-
-case class MoveRequest(
- gameId: String,
- fen: String,
- turn: String,
- playingAs: String,
- difficulty: Int,
- botAccountId: String,
-)
-
-object MoveRequestParser:
- def parse(json: String, mapper: ObjectMapper): Option[MoveRequest] =
- scala.util.Try {
- val node = mapper.readTree(json)
- MoveRequest(
- gameId = node.get("gameId").asText(),
- fen = node.get("fen").asText(),
- turn = node.get("turn").asText(),
- playingAs = node.get("playingAs").asText(),
- difficulty = node.get("difficulty").asInt(1400),
- botAccountId = node.get("botAccountId").asText(),
- )
- }.toOption
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
index 0d1a348..f2d76b3 100644
--- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
@@ -2,7 +2,6 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
-import de.nowchess.bot.Bot
import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.config.RedisConfig
@@ -12,6 +11,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
+import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
@ApplicationScoped
@@ -24,35 +24,72 @@ class OfficialBotService:
@Inject var botController: BotController = uninitialized
// scalafix:on DisableSyntax.var
+ private val terminalStatuses =
+ Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
+
def onStart(@Observes event: StartupEvent): Unit =
- Thread.ofVirtual().start(() => runWorker())
+ BotController.listBots.foreach(subscribeToEventChannel)
+
+ private def subscribeToEventChannel(botName: String): Unit =
+ val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botName:events")
+ topic.addListener(
+ classOf[String],
+ new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ handleBotEvent(botName, msg),
+ )
()
- private def runWorker(): Unit =
- val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
- while true do
- try
- val json = queue.take()
- MoveRequestParser.parse(json, objectMapper).foreach(processRequest)
- catch case _: InterruptedException => Thread.currentThread().interrupt()
+ private def handleBotEvent(botName: String, msg: String): Unit =
+ try
+ val node = objectMapper.readTree(msg)
+ if node.path("type").asText() == "gameStart" then
+ val gameId = node.path("gameId").asText()
+ val playingAs = node.path("playingAs").asText()
+ val difficulty = node.path("difficulty").asInt(1400)
+ val botAccountId = node.path("botAccountId").asText()
+ watchGame(botName, gameId, playingAs, difficulty, botAccountId)
+ catch case _: Exception => ()
- private def processRequest(req: MoveRequest): Unit =
- val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium)
- val botName = difficulty match
- case BotDifficulty.Easy => "easy"
- case BotDifficulty.Medium => "medium"
- case BotDifficulty.Hard => "hard"
- case BotDifficulty.Expert => "expert"
- botController.getBot(botName).foreach(bot => parseAndMove(req, bot))
+ private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
+ val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c")
+ topic.addListener(
+ classOf[String],
+ new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
+ )
+ ()
- private def parseAndMove(req: MoveRequest, bot: Bot): Unit =
- FenParser.parseFen(req.fen).toOption.foreach { context =>
- bot(context).foreach { move =>
- val uci = toUci(move)
- val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s"
- val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}"""
- redisson.getTopic(c2sTopic).publish(moveMsg)
- ()
+ private def handleGameEvent(
+ botName: String,
+ gameId: String,
+ playingAs: String,
+ difficulty: Int,
+ botAccountId: String,
+ msg: String,
+ ): Unit =
+ try
+ val node = objectMapper.readTree(msg)
+ val status = node.path("state").path("status").asText("")
+ if !terminalStatuses.contains(status) then
+ val turn = node.path("state").path("turn").asText("")
+ if turn == playingAs then
+ val fen = node.path("state").path("fen").asText()
+ computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
+ catch case _: Exception => ()
+
+ private def computeAndSendMove(botName: String, gameId: String, fen: String, difficulty: Int, botAccountId: String): Unit =
+ val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
+ botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
+ FenParser.parseFen(fen).toOption.foreach { context =>
+ bot(context).foreach { move =>
+ val uci = toUci(move)
+ val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
+ val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
+ redisson.getTopic(c2sTopic).publish(moveMsg)
+ ()
+ }
}
}
diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts
index ce82b80..91455b5 100644
--- a/modules/rule/build.gradle.kts
+++ b/modules/rule/build.gradle.kts
@@ -52,6 +52,7 @@ dependencies {
implementation(project(":modules:api"))
implementation(project(":modules:json"))
+ implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
diff --git a/modules/rule/src/main/resources/application.yml b/modules/rule/src/main/resources/application.yml
index 7a998fc..da00829 100644
--- a/modules/rule/src/main/resources/application.yml
+++ b/modules/rule/src/main/resources/application.yml
@@ -6,3 +6,7 @@ quarkus:
use-separate-server: false
application:
name: rule-service
+
+nowchess:
+ internal:
+ secret: ${INTERNAL_SECRET}
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala
index 4027603..66b0e66 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala
+++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala
@@ -5,6 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.dto.*
import de.nowchess.api.rules.PostMoveStatus
+import de.nowchess.security.InternalOnly
import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.*
@@ -12,6 +13,7 @@ import jakarta.ws.rs.core.MediaType
@Path("/api/rules")
@ApplicationScoped
+@InternalOnly
class RuleSetResource:
private val rules = DefaultRules
diff --git a/modules/security/build.gradle.kts b/modules/security/build.gradle.kts
new file mode 100644
index 0000000..c6166a2
--- /dev/null
+++ b/modules/security/build.gradle.kts
@@ -0,0 +1,65 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version { strictly(versions["SCALA3"]!!) }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version { strictly(versions["SCALA3"]!!) }
+ }
+
+ compileOnly(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ compileOnly("io.quarkus:quarkus-rest")
+ compileOnly("io.quarkus:quarkus-rest-client")
+ compileOnly("io.quarkus:quarkus-grpc")
+ compileOnly("io.quarkus:quarkus-arc")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
diff --git a/modules/security/src/main/java/de/nowchess/security/InternalOnly.java b/modules/security/src/main/java/de/nowchess/security/InternalOnly.java
new file mode 100644
index 0000000..111cbca
--- /dev/null
+++ b/modules/security/src/main/java/de/nowchess/security/InternalOnly.java
@@ -0,0 +1,12 @@
+package de.nowchess.security;
+
+import jakarta.ws.rs.NameBinding;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@NameBinding
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface InternalOnly {}
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalAuthFilter.scala b/modules/security/src/main/scala/de/nowchess/security/InternalAuthFilter.scala
new file mode 100644
index 0000000..0ba59ff
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalAuthFilter.scala
@@ -0,0 +1,23 @@
+package de.nowchess.security
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
+import jakarta.ws.rs.core.Response
+import jakarta.ws.rs.ext.Provider
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@Provider
+@InternalOnly
+@ApplicationScoped
+class InternalAuthFilter extends ContainerRequestFilter:
+
+ @ConfigProperty(name = "nowchess.internal.secret")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def filter(ctx: ContainerRequestContext): Unit =
+ val header = ctx.getHeaderString("X-Internal-Secret")
+ if header == null || header != secret then
+ ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build())
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalGrpcAuthInterceptor.scala b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcAuthInterceptor.scala
new file mode 100644
index 0000000..ac6c447
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcAuthInterceptor.scala
@@ -0,0 +1,30 @@
+package de.nowchess.security
+
+import io.grpc.{Metadata, ServerCall, ServerCallHandler, ServerInterceptor, Status}
+import io.quarkus.grpc.GlobalInterceptor
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@GlobalInterceptor
+@ApplicationScoped
+class InternalGrpcAuthInterceptor extends ServerInterceptor:
+
+ private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
+
+ @ConfigProperty(name = "nowchess.internal.secret")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def interceptCall[Req, Resp](
+ call: ServerCall[Req, Resp],
+ headers: Metadata,
+ next: ServerCallHandler[Req, Resp],
+ ): ServerCall.Listener[Req] =
+ val token = Option(headers.get(secretKey)).getOrElse("")
+ if token != secret then
+ call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid internal secret"), new Metadata())
+ new ServerCall.Listener[Req] {}
+ else
+ next.startCall(call, headers)
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalGrpcSecretClientInterceptor.scala b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcSecretClientInterceptor.scala
new file mode 100644
index 0000000..9f6c76e
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcSecretClientInterceptor.scala
@@ -0,0 +1,28 @@
+package de.nowchess.security
+
+import io.grpc.{CallOptions, Channel, ClientCall, ClientInterceptor, ForwardingClientCall, Metadata, MethodDescriptor}
+import io.quarkus.grpc.GlobalInterceptor
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@GlobalInterceptor
+@ApplicationScoped
+class InternalGrpcSecretClientInterceptor extends ClientInterceptor:
+
+ private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
+
+ @ConfigProperty(name = "nowchess.internal.secret")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def interceptCall[Req, Resp](
+ method: MethodDescriptor[Req, Resp],
+ callOptions: CallOptions,
+ next: Channel,
+ ): ClientCall[Req, Resp] =
+ new ForwardingClientCall.SimpleForwardingClientCall[Req, Resp](next.newCall(method, callOptions)):
+ override def start(responseListener: ClientCall.Listener[Resp], headers: Metadata): Unit =
+ headers.put(secretKey, secret)
+ super.start(responseListener, headers)
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalSecretClientFilter.scala b/modules/security/src/main/scala/de/nowchess/security/InternalSecretClientFilter.scala
new file mode 100644
index 0000000..9b56bdc
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalSecretClientFilter.scala
@@ -0,0 +1,17 @@
+package de.nowchess.security
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.client.{ClientRequestContext, ClientRequestFilter}
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class InternalSecretClientFilter extends ClientRequestFilter:
+
+ @ConfigProperty(name = "nowchess.internal.secret")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def filter(ctx: ClientRequestContext): Unit =
+ ctx.getHeaders.putSingle("X-Internal-Secret", secret)
diff --git a/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala b/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala
index 3e4f76c..ecfd9a9 100644
--- a/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala
+++ b/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala
@@ -6,7 +6,13 @@ import scala.compiletime.uninitialized
import java.time.Instant
@Entity
-@Table(name = "game_records")
+@Table(
+ name = "game_records",
+ indexes = Array(
+ new Index(name = "idx_game_records_white_id", columnList = "whiteId"),
+ new Index(name = "idx_game_records_black_id", columnList = "blackId"),
+ ),
+)
class GameRecord extends PanacheEntityBase:
// scalafix:off DisableSyntax.var
@Id
@@ -79,4 +85,11 @@ class GameRecord extends PanacheEntityBase:
@Column
var pendingDrawOffer: String = uninitialized
+
+ // Game result
+ @Column
+ var result: String = uninitialized
+
+ @Column
+ var terminationReason: String = uninitialized
// scalafix:on
diff --git a/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackEventDto.scala b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackEventDto.scala
index 2eef25b..9265d32 100644
--- a/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackEventDto.scala
+++ b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackEventDto.scala
@@ -21,4 +21,6 @@ case class GameWritebackEventDto(
clockMoveDeadline: Option[Long],
clockActiveColor: Option[String],
pendingDrawOffer: Option[String],
+ result: Option[String] = None,
+ terminationReason: Option[String] = None,
)
diff --git a/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala
index 8568112..4f9b53a 100644
--- a/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala
+++ b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala
@@ -24,8 +24,6 @@ class GameWritebackStreamListener:
val topic = redisson.getTopic("game-writeback")
topic.addListener(
classOf[String],
- new MessageListener[String]:
- def onMessage(channel: CharSequence, json: String): Unit =
- Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
- .foreach(writebackService.writeBack),
+ (channel: CharSequence, json: String) => Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
+ .foreach(writebackService.writeBack)
)
diff --git a/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala b/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala
index 506097d..f012685 100644
--- a/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala
+++ b/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala
@@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.persistence.EntityManager
import scala.compiletime.uninitialized
+import scala.jdk.CollectionConverters.*
@ApplicationScoped
class GameRecordRepository:
@@ -21,3 +22,25 @@ class GameRecordRepository:
def merge(record: GameRecord): Unit =
em.merge(record)
+
+ def findByPlayerId(playerId: String, offset: Int, limit: Int): List[GameRecord] =
+ em.createQuery(
+ "SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id ORDER BY g.updatedAt DESC",
+ classOf[GameRecord],
+ ).setParameter("id", playerId)
+ .setFirstResult(offset)
+ .setMaxResults(limit)
+ .getResultList
+ .asScala
+ .toList
+
+ def findByPlayerIdRunning(playerId: String, offset: Int, limit: Int): List[GameRecord] =
+ em.createQuery(
+ "SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result = null ORDER BY g.updatedAt DESC",
+ classOf[GameRecord],
+ ).setParameter("id", playerId)
+ .setFirstResult(offset)
+ .setMaxResults(limit)
+ .getResultList
+ .asScala
+ .toList
diff --git a/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala b/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala
index 7fa1f06..35b3331 100644
--- a/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala
+++ b/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala
@@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
+import jakarta.ws.rs.DefaultValue
import scala.compiletime.uninitialized
@Path("/game")
@@ -22,3 +23,23 @@ class StoreGameResource:
repository
.findByGameId(gameId)
.fold(Response.status(404).build())(r => Response.ok(r).build())
+
+ @GET
+ @Path("/running/{playerId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getRunning(
+ @PathParam("playerId") playerId: String,
+ @QueryParam("offset") @DefaultValue("0") offset: Int,
+ @QueryParam("limit") @DefaultValue("20") limit: Int,
+ ): Response =
+ Response.ok(repository.findByPlayerIdRunning(playerId, offset, limit)).build()
+
+ @GET
+ @Path("/history/{playerId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getHistory(
+ @PathParam("playerId") playerId: String,
+ @QueryParam("offset") @DefaultValue("0") offset: Int,
+ @QueryParam("limit") @DefaultValue("20") limit: Int,
+ ): Response =
+ Response.ok(repository.findByPlayerId(playerId, offset, limit)).build()
diff --git a/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala b/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala
index a6929b1..b235bd5 100644
--- a/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala
+++ b/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala
@@ -41,6 +41,8 @@ class GameWritebackService:
record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
record.clockActiveColor = event.clockActiveColor.orNull
record.pendingDrawOffer = event.pendingDrawOffer.orNull
+ record.result = event.result.orNull
+ record.terminationReason = event.terminationReason.orNull
record.createdAt = Instant.now()
record.updatedAt = Instant.now()
repository.persist(record)
@@ -64,6 +66,8 @@ class GameWritebackService:
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
r.clockActiveColor = event.clockActiveColor.orNull
r.pendingDrawOffer = event.pendingDrawOffer.orNull
+ r.result = event.result.orNull
+ r.terminationReason = event.terminationReason.orNull
r.updatedAt = Instant.now()
repository.merge(r)
case _ => ()
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/resource/UserWebSocketResource.scala b/modules/ws/src/main/scala/de/nowchess/ws/resource/UserWebSocketResource.scala
new file mode 100644
index 0000000..cc3734e
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/resource/UserWebSocketResource.scala
@@ -0,0 +1,57 @@
+package de.nowchess.ws.resource
+
+import de.nowchess.ws.config.RedisConfig
+import io.quarkus.websockets.next.*
+import io.smallrye.jwt.auth.principal.JWTParser
+import jakarta.inject.Inject
+import org.redisson.api.listener.MessageListener
+import org.redisson.api.RedissonClient
+
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.util.concurrent.ConcurrentHashMap
+
+@WebSocket(path = "/api/user/ws")
+class UserWebSocketResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var redisson: RedissonClient = uninitialized
+
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+
+ @Inject
+ var jwtParser: JWTParser = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val connections = new ConcurrentHashMap[String, (String, Int)]()
+
+ private def userTopic(userId: String): String =
+ s"${redisConfig.prefix}:user:$userId:events"
+
+ @OnOpen
+ def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
+ val userIdOpt = Option(handshake.header("Authorization"))
+ .filter(_.nonEmpty)
+ .flatMap(token => Try(jwtParser.parse(token)).toOption)
+ .map(_.getSubject)
+
+ userIdOpt match
+ case None => connection.close().subscribe().`with`(_ => (), _ => ())
+ case Some(userId) =>
+ val listenerId = redisson.getTopic(userTopic(userId)).addListener(
+ classOf[String],
+ new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
+ )
+ connections.put(connection.id(), (userId, listenerId))
+ val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
+ connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
+
+ @OnClose
+ def onClose(connection: WebSocketConnection): Unit =
+ Option(connections.remove(connection.id())).foreach { (userId, listenerId) =>
+ redisson.getTopic(userTopic(userId)).removeListener(listenerId)
+ }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d709bcc..5912991 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,6 +19,7 @@ include(
"modules:json",
"modules:io",
"modules:rule",
+ "modules:security",
"modules:bot-platform",
"modules:official-bots",
"modules:account",