diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7d3788b..92c218c 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -17,6 +17,7 @@
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 7e3ab9c..238131e 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/bruno/board/game/02 Get Game.bru b/bruno/board/game/02 Get Game.bru
index c3c0c00..a8e7dfc 100644
--- a/bruno/board/game/02 Get Game.bru
+++ b/bruno/board/game/02 Get Game.bru
@@ -11,5 +11,5 @@ get {
}
vars:pre-request {
- gameId: Yg200tOF
+ gameId: j0nPtcjl
}
diff --git a/bruno/board/game/03 Stream Game.bru b/bruno/board/game/03 Stream Game.bru
index b669537..cbc6a17 100644
--- a/bruno/board/game/03 Stream Game.bru
+++ b/bruno/board/game/03 Stream Game.bru
@@ -11,12 +11,24 @@ ws {
}
body:ws {
- name: message 1
+ name: move
content: '''
- {}
+ {
+ "type": "MOVE",
+ "uci": "b1c3"
+ }
+ '''
+}
+
+body:ws {
+ name: ping
+ content: '''
+ {
+ "type": "PING"
+ }
'''
}
vars:pre-request {
- gameId: uWm99efJ
+ gameId: j0nPtcjl
}
diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru
index dadc90c..7ec255f 100644
--- a/bruno/environments/local.bru
+++ b/bruno/environments/local.bru
@@ -1,5 +1,5 @@
vars {
baseUrl: http://localhost:8080
- wsBaseUrl: ws://localhost:8080
+ wsBaseUrl: ws://localhost:8084
ioBaseUrl: http://localhost:8081
}
diff --git a/build.gradle.kts b/build.gradle.kts
index eb0b26c..86254f1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -46,7 +46,11 @@ val coverageExclusions = listOf(
// AccountResource / ChallengeResource — REST integration layer; @QuarkusTest not instrumented by Scoverage
"**/account/src/main/scala/de/nowchess/account/resource/**",
// JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic
- "**/account/src/main/scala/de/nowchess/account/config/**"
+ "**/account/src/main/scala/de/nowchess/account/config/**",
+ // WebSocket service — infrastructure CDI beans (RedisConfig, RedissonProducer)
+ "**/ws/src/main/scala/de/nowchess/ws/config/**",
+ // GameWebSocketResource in core — replaced by ws module
+ "**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
)
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
@@ -93,7 +97,8 @@ val versions = mapOf(
"SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.2",
- "JACKSON_SCALA" to "2.17.2"
+ "JACKSON_SCALA" to "2.17.2",
+ "REDISSON" to "3.32.0"
)
extra["VERSIONS"] = versions
diff --git a/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala b/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala
index 04ee512..b519d70 100644
--- a/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala
@@ -30,8 +30,10 @@ class AlreadyLoggedInFilter extends ContainerRequestFilter:
)
private def isAuthenticated: Boolean =
+ // scalafix:off DisableSyntax.null
try jwt.getName != null
catch case _ => false
+ // scalafix:on DisableSyntax.null
private def isProtectedEndpoint(path: String, method: String): Boolean =
(path.contains("/api/account") || path.contains("/account")) &&
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
index 8a6b3bd..42688e1 100644
--- a/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
@@ -10,4 +10,5 @@ final case class GameStateDto(
undoAvailable: Boolean,
redoAvailable: Boolean,
clock: Option[ClockDto],
+ takebackRequestedBy: Option[String] = None,
)
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index 22aba12..24267f6 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -70,7 +70,7 @@ dependencies {
implementation("io.quarkus:quarkus-websockets-next")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
-
+ implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
testImplementation(project(":modules:io"))
testImplementation(project(":modules:rule"))
@@ -124,3 +124,18 @@ tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ exclude("**/resource/GameDtoMapper.scala")
+ exclude("**/resource/GameResource.scala")
+ exclude("**/redis/GameRedis*.scala")
+ }
+ }
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+}
+
diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml
index 53a6d58..383bbf9 100644
--- a/modules/core/src/main/resources/application.yml
+++ b/modules/core/src/main/resources/application.yml
@@ -14,6 +14,12 @@ quarkus:
server:
use-separate-server: false
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
"%dev":
mp:
jwt:
@@ -41,6 +47,8 @@ quarkus:
url: http://localhost:8081
rule-service:
url: http://localhost:8082
+ store-service:
+ url: http://localhost:8085
"%deployed":
mp:
@@ -69,3 +77,10 @@ quarkus:
url: ${IO_SERVICE_URL}
rule-service:
url: ${RULE_SERVICE_URL}
+ store-service:
+ url: ${STORE_SERVICE_URL}
+ nowchess:
+ redis:
+ host: ${REDIS_HOST}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/GameRecordDto.scala b/modules/core/src/main/scala/de/nowchess/chess/client/GameRecordDto.scala
new file mode 100644
index 0000000..c5dea0b
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/GameRecordDto.scala
@@ -0,0 +1,24 @@
+package de.nowchess.chess.client
+
+case class GameRecordDto(
+ gameId: String,
+ fen: String,
+ pgn: String,
+ moveCount: Int,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ resigned: Boolean,
+ limitSeconds: java.lang.Integer,
+ incrementSeconds: java.lang.Integer,
+ daysPerMove: java.lang.Integer,
+ whiteRemainingMs: java.lang.Long,
+ blackRemainingMs: java.lang.Long,
+ incrementMs: java.lang.Long,
+ clockLastTickAt: java.lang.Long,
+ clockMoveDeadline: java.lang.Long,
+ clockActiveColor: String,
+ pendingDrawOffer: String,
+)
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
new file mode 100644
index 0000000..8ee740b
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala
@@ -0,0 +1,13 @@
+package de.nowchess.chess.client
+
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
+
+@RegisterRestClient(configKey = "store-service")
+@Path("/game")
+trait StoreServiceClient:
+ @GET
+ @Path("/{gameId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getGame(@PathParam("gameId") gameId: String): GameRecordDto
diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala
deleted file mode 100644
index 68c3c9b..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala
+++ /dev/null
@@ -1,60 +0,0 @@
-package de.nowchess.chess.command
-
-import de.nowchess.api.board.{Piece, Square}
-import de.nowchess.api.game.GameContext
-
-/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
- * transitions.
- */
-trait Command:
- /** Execute the command and return true if successful, false otherwise. */
- def execute(): Boolean
-
- /** Undo the command and return true if successful, false otherwise. */
- def undo(): Boolean
-
- /** A human-readable description of this command. */
- def description: String
-
-/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
- */
-case class MoveCommand(
- from: Square,
- to: Square,
- moveResult: Option[MoveResult] = None,
- previousContext: Option[GameContext] = None,
- notation: String = "",
-) extends Command:
-
- override def execute(): Boolean =
- moveResult.isDefined
-
- override def undo(): Boolean =
- previousContext.isDefined
-
- override def description: String = s"Move from $from to $to"
-
-// Sealed hierarchy of move outcomes (for tracking state changes)
-sealed trait MoveResult
-object MoveResult:
- case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
- case object InvalidFormat extends MoveResult
- case object InvalidMove extends MoveResult
-
-/** Command to quit the game. */
-case class QuitCommand() extends Command:
- override def execute(): Boolean = true
- override def undo(): Boolean = false
- override def description: String = "Quit game"
-
-/** Command to reset the board to initial position. */
-case class ResetCommand(
- previousContext: Option[GameContext] = None,
-) extends Command:
-
- override def execute(): Boolean = true
-
- override def undo(): Boolean =
- previousContext.isDefined
-
- override def description: String = "Reset board"
diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala
deleted file mode 100644
index 78760ef..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala
+++ /dev/null
@@ -1,67 +0,0 @@
-package de.nowchess.chess.command
-
-/** Manages command execution and history for undo/redo support. */
-class CommandInvoker:
- private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
- @SuppressWarnings(Array("DisableSyntax.var"))
- private var currentIndex = -1
-
- /** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
- */
- def execute(command: Command): Boolean = synchronized {
- if command.execute() then
- // Remove any commands after current index (redo stack is discarded)
- while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
- executedCommands += command
- currentIndex += 1
- true
- else false
- }
-
- /** Undo the last executed command if possible. */
- def undo(): Boolean = synchronized {
- if currentIndex >= 0 && currentIndex < executedCommands.size then
- val command = executedCommands(currentIndex)
- if command.undo() then
- currentIndex -= 1
- true
- else false
- else false
- }
-
- /** Redo the next command in history if available. */
- def redo(): Boolean = synchronized {
- if currentIndex + 1 < executedCommands.size then
- val command = executedCommands(currentIndex + 1)
- if command.execute() then
- currentIndex += 1
- true
- else false
- else false
- }
-
- /** Get the history of all executed commands. */
- def history: List[Command] = synchronized {
- executedCommands.toList
- }
-
- /** Get the current position in command history. */
- def getCurrentIndex: Int = synchronized {
- currentIndex
- }
-
- /** Clear all command history. */
- def clear(): Unit = synchronized {
- executedCommands.clear()
- currentIndex = -1
- }
-
- /** Check if undo is available. */
- def canUndo: Boolean = synchronized {
- currentIndex >= 0
- }
-
- /** Check if redo is available. */
- def canRedo: Boolean = synchronized {
- currentIndex + 1 < executedCommands.size
- }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala
new file mode 100644
index 0000000..c583f1f
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala
@@ -0,0 +1,18 @@
+package de.nowchess.chess.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/core/src/main/scala/de/nowchess/chess/config/RedissonProducer.scala b/modules/core/src/main/scala/de/nowchess/chess/config/RedissonProducer.scala
new file mode 100644
index 0000000..12ac5b5
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/RedissonProducer.scala
@@ -0,0 +1,35 @@
+package de.nowchess.chess.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/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
index 10be40f..a2065ff 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
@@ -18,7 +18,6 @@ import de.nowchess.api.game.{
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
-import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.api.error.GameError
import de.nowchess.api.game.WinReason.{Checkmate, Resignation}
import de.nowchess.api.io.{GameContextExport, GameContextImport}
@@ -39,6 +38,10 @@ class GameEngine(
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
),
val timeControl: TimeControl = TimeControl.Unlimited,
+ initialClockState: Option[ClockState] = None,
+ initialDrawOffer: Option[Color] = None,
+ initialRedoStack: List[Move] = Nil,
+ initialTakebackRequest: Option[Color] = None,
) extends Observable:
// Ensure that initialBoard is set correctly for threefold repetition detection
private val contextWithInitialBoard =
@@ -48,15 +51,20 @@ class GameEngine(
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard
@SuppressWarnings(Array("DisableSyntax.var"))
- private var pendingDrawOffer: Option[Color] = None
+ private var pendingDrawOffer: Option[Color] = initialDrawOffer
@SuppressWarnings(Array("DisableSyntax.var"))
private var clockState: Option[ClockState] =
- ClockState.fromTimeControl(timeControl, contextWithInitialBoard.turn, Instant.now())
+ initialClockState.orElse(ClockState.fromTimeControl(timeControl, contextWithInitialBoard.turn, Instant.now()))
@SuppressWarnings(Array("DisableSyntax.var"))
private var scheduledCheck: Option[ScheduledFuture[?]] = None
// One shared scheduler per engine; shut down with the game.
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
- private val invoker = new CommandInvoker()
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var redoStack: List[Move] = initialRedoStack
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var isRedoing: Boolean = false
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var pendingTakebackRequest: Option[Color] = initialTakebackRequest
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
clockState.foreach(scheduleExpiryCheck)
@@ -71,13 +79,16 @@ class GameEngine(
def currentClockState: Option[ClockState] = synchronized(clockState)
/** Check if undo is available. */
- def canUndo: Boolean = synchronized(invoker.canUndo)
+ def canUndo: Boolean = synchronized(currentContext.moves.nonEmpty)
/** Check if redo is available. */
- def canRedo: Boolean = synchronized(invoker.canRedo)
+ def canRedo: Boolean = synchronized(redoStack.nonEmpty)
- /** Get the command history for inspection (testing/debugging). */
- def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
+ /** Get redo stack moves for inspection. */
+ def redoStackMoves: List[Move] = synchronized(redoStack)
+
+ /** Get pending takeback request (if any). */
+ def pendingTakebackRequestBy: Option[Color] = synchronized(pendingTakebackRequest)
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
* GameEvent.
@@ -162,8 +173,9 @@ class GameEngine(
else
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
pendingDrawOffer = None
+ pendingTakebackRequest = None
stopClock()
- invoker.clear()
+ redoStack = Nil
notifyObservers(ResignEvent(currentContext, color))
}
@@ -193,8 +205,9 @@ class GameEngine(
case Some(_) =>
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
pendingDrawOffer = None
+ pendingTakebackRequest = None
stopClock()
- invoker.clear()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
}
@@ -220,12 +233,12 @@ class GameEngine(
else if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
stopClock()
- invoker.clear()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
stopClock()
- invoker.clear()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
}
@@ -239,6 +252,8 @@ class GameEngine(
case Right(ctx) =>
replayGame(ctx).map { _ =>
pendingDrawOffer = None
+ pendingTakebackRequest = None
+ redoStack = Nil
stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(PgnLoadedEvent(currentContext))
@@ -248,7 +263,7 @@ class GameEngine(
private def replayGame(ctx: GameContext): Either[GameError, Unit] =
val savedContext = currentContext
currentContext = GameContext.initial
- invoker.clear()
+ redoStack = Nil
if ctx.moves.isEmpty then
currentContext = ctx.copy(initialBoard = ctx.board)
@@ -283,9 +298,10 @@ class GameEngine(
else newContext
currentContext = contextWithInitialBoard
pendingDrawOffer = None
+ pendingTakebackRequest = None
+ redoStack = Nil
stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
- invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
@@ -293,9 +309,10 @@ class GameEngine(
def reset(): Unit = synchronized {
currentContext = GameContext.initial
pendingDrawOffer = None
+ pendingTakebackRequest = None
+ redoStack = Nil
stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
- invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
@@ -304,7 +321,7 @@ class GameEngine(
if currentContext.result.isEmpty then
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
stopClock()
- invoker.clear()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, reason))
}
@@ -329,7 +346,8 @@ class GameEngine(
else GameResult.Win(flagged.opposite, WinReason.TimeControl)
currentContext = currentContext.withResult(Some(result))
pendingDrawOffer = None
- invoker.clear()
+ pendingTakebackRequest = None
+ redoStack = Nil
notifyObservers(TimeFlagEvent(currentContext, flagged))
private def scheduleExpiryCheck(cs: ClockState): Unit =
@@ -365,19 +383,15 @@ class GameEngine(
// ──── Private helpers ────
private def executeMove(move: Move): Unit =
+ if !isRedoing then
+ redoStack = Nil
+ pendingTakebackRequest = None
+
val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
-
- val cmd = MoveCommand(
- from = move.from,
- to = move.to,
- moveResult = Some(MoveResult.Successful(nextContext, captured)),
- previousContext = Some(contextBefore),
- notation = translateMoveToNotation(move, contextBefore.board),
- )
- invoker.execute(cmd)
- currentContext = nextContext
+ val notation = translateMoveToNotation(move, contextBefore.board)
+ currentContext = nextContext
advanceClock(contextBefore.turn)
@@ -397,17 +411,17 @@ class GameEngine(
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
cancelScheduled()
notifyObservers(CheckmateEvent(currentContext, winner))
- invoker.clear()
+ redoStack = Nil
else if status.isStalemate then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
- invoker.clear()
+ redoStack = Nil
else if status.isInsufficientMaterial then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
- invoker.clear()
+ redoStack = Nil
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
@@ -504,32 +518,68 @@ class GameEngine(
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
}
+ private def replayContextFromMoves(moves: List[Move]): GameContext =
+ moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
+
private def performUndo(): Unit =
- if invoker.canUndo then
- val cmd = invoker.history(invoker.getCurrentIndex)
- (cmd: @unchecked) match
- case moveCmd: MoveCommand =>
- moveCmd.previousContext.foreach(currentContext = _)
- invoker.undo()
- notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
- else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
+ if currentContext.moves.isEmpty then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
+ else
+ val lastMove = currentContext.moves.last
+ val prevCtx = replayContextFromMoves(currentContext.moves.dropRight(1))
+ val notation = translateMoveToNotation(lastMove, prevCtx.board)
+ redoStack = lastMove :: redoStack
+ currentContext = prevCtx
+ notifyObservers(MoveUndoneEvent(currentContext, notation))
private def performRedo(): Unit =
- if invoker.canRedo then
- val cmd = invoker.history(invoker.getCurrentIndex + 1)
- (cmd: @unchecked) match
- case moveCmd: MoveCommand =>
- for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
- currentContext = nextCtx
- invoker.redo()
- val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
- notifyObservers(
- MoveRedoneEvent(
- currentContext,
- moveCmd.notation,
- moveCmd.from.toString,
- moveCmd.to.toString,
- capturedDesc,
- ),
- )
- else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
+ if redoStack.isEmpty then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
+ else
+ val move = redoStack.head
+ redoStack = redoStack.tail
+ isRedoing = true
+ executeMove(move)
+ isRedoing = false
+
+ def requestTakeback(color: Color): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else if currentContext.moves.isEmpty then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
+ else
+ pendingTakebackRequest match
+ case Some(_) =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.TakebackRequestPending))
+ case None =>
+ pendingTakebackRequest = Some(color)
+ notifyObservers(TakebackRequestedEvent(currentContext, color))
+ }
+
+ def acceptTakeback(color: Color): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else
+ pendingTakebackRequest match
+ case None =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoTakebackRequestToAccept))
+ case Some(requester) if requester == color =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotAcceptOwnTakebackRequest))
+ case Some(_) =>
+ pendingTakebackRequest = None
+ performUndo()
+ }
+
+ def declineTakeback(color: Color): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else
+ pendingTakebackRequest match
+ case None =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoTakebackRequestToDecline))
+ case Some(requester) if requester == color =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotDeclineOwnTakebackRequest))
+ case Some(_) =>
+ pendingTakebackRequest = None
+ notifyObservers(TakebackDeclinedEvent(currentContext, color))
+ }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala
index dce32ab..4418592 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala
@@ -19,3 +19,8 @@ enum InvalidMoveReason:
case CannotAcceptOwnDrawOffer
case NoDrawOfferToDecline
case CannotDeclineOwnDrawOffer
+ case TakebackRequestPending
+ case NoTakebackRequestToAccept
+ case CannotAcceptOwnTakebackRequest
+ case NoTakebackRequestToDecline
+ case CannotDeclineOwnTakebackRequest
diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
index b5d909b..a52df65 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
@@ -60,15 +60,6 @@ case class MoveUndoneEvent(
pgnNotation: String,
) extends GameEvent
-/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
-case class MoveRedoneEvent(
- context: GameContext,
- pgnNotation: String,
- fromSquare: String,
- toSquare: String,
- capturedPiece: Option[String],
-) extends GameEvent
-
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent(
context: GameContext,
@@ -98,6 +89,18 @@ case class TimeFlagEvent(
flaggedColor: Color,
) extends GameEvent
+/** Fired when a player requests a takeback of the last move. */
+case class TakebackRequestedEvent(
+ context: GameContext,
+ requestedBy: Color,
+) extends GameEvent
+
+/** Fired when a player declines a takeback request. */
+case class TakebackDeclinedEvent(
+ context: GameContext,
+ declinedBy: Color,
+) extends GameEvent
+
/** Observer trait: implement to receive game state updates. */
trait Observer:
def onGameEvent(event: GameEvent): Unit
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala
new file mode 100644
index 0000000..af69100
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala
@@ -0,0 +1,8 @@
+package de.nowchess.chess.redis
+
+sealed trait C2sMessage
+
+object C2sMessage:
+ case object Connected extends C2sMessage
+ case class Move(uci: String) extends C2sMessage
+ case object Ping extends C2sMessage
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
new file mode 100644
index 0000000..231cf83
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala
@@ -0,0 +1,56 @@
+package de.nowchess.chess.redis
+
+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.chess.observer.{GameEvent, Observer}
+import de.nowchess.chess.registry.GameRegistry
+import de.nowchess.chess.resource.GameDtoMapper
+import org.redisson.api.RTopic
+
+class GameRedisPublisher(
+ gameId: String,
+ registry: GameRegistry,
+ redisson: org.redisson.api.RedissonClient,
+ objectMapper: ObjectMapper,
+ s2cTopicName: String,
+ writebackEmit: String => Unit,
+ ioClient: IoGrpcClientWrapper,
+ onGameOver: String => Unit,
+) extends Observer:
+
+ def onGameEvent(event: GameEvent): Unit =
+ registry.get(gameId).foreach { entry =>
+ val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
+ val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
+ redisson.getTopic(s2cTopicName).publish(json)
+
+ val clock = entry.engine.currentClockState
+ val wb = GameWritebackEventDto(
+ gameId = gameId,
+ fen = dto.fen,
+ pgn = dto.pgn,
+ moveCount = entry.engine.context.moves.size,
+ whiteId = entry.white.id.value,
+ whiteName = entry.white.displayName,
+ blackId = entry.black.id.value,
+ blackName = entry.black.displayName,
+ mode = entry.mode.toString,
+ resigned = entry.resigned,
+ limitSeconds = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Clock(l, _) => Some(l); case _ => None },
+ incrementSeconds = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Clock(_, i) => Some(i); case _ => None },
+ daysPerMove = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Correspondence(d) => Some(d); case _ => None },
+ whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
+ blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
+ incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
+ clockLastTickAt = clock.collect { case c: LiveClockState => c.lastTickAt.toEpochMilli },
+ clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
+ clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
+ pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
+ redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
+ pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
+ )
+ writebackEmit(objectMapper.writeValueAsString(wb))
+ if entry.engine.context.result.isDefined then onGameOver(gameId)
+ }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala
new file mode 100644
index 0000000..bbb8705
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala
@@ -0,0 +1,102 @@
+package de.nowchess.chess.redis
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.dto.GameFullEventDto
+import de.nowchess.chess.config.RedisConfig
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.chess.observer.Observer
+import de.nowchess.chess.registry.GameRegistry
+import de.nowchess.chess.resource.GameDtoMapper
+import jakarta.annotation.PreDestroy
+import jakarta.enterprise.context.ApplicationScoped
+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
+
+@ApplicationScoped
+class GameRedisSubscriberManager:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redisson: RedissonClient = uninitialized
+ @Inject var registry: GameRegistry = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var ioClient: IoGrpcClientWrapper = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val c2sListeners = new ConcurrentHashMap[String, Int]()
+ private val s2cObservers = new ConcurrentHashMap[String, Observer]()
+
+ private def c2sTopic(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:c2s"
+
+ private def s2cTopicName(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:s2c"
+
+ def subscribeGame(gameId: String): Unit =
+ try
+ val topic = redisson.getTopic(c2sTopic(gameId))
+ val listenerId = topic.addListener(classOf[String], new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ handleC2sMessage(gameId, msg)
+ )
+ c2sListeners.put(gameId, listenerId)
+
+ val writebackTopic = redisson.getTopic("game-writeback")
+ val writebackFn: String => Unit = json => writebackTopic.publish(json)
+ val obs = new GameRedisPublisher(gameId, registry, redisson, objectMapper, s2cTopicName(gameId), writebackFn, ioClient, unsubscribeGame)
+ s2cObservers.put(gameId, obs)
+ registry.get(gameId).foreach(_.engine.subscribe(obs))
+ catch
+ case e: Exception =>
+ System.err.println(s"Warning: Redis subscription failed for game $gameId: ${e.getMessage}")
+ ()
+
+ def unsubscribeGame(gameId: String): Unit =
+ Option(c2sListeners.remove(gameId)).foreach { listenerId =>
+ redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)
+ }
+ Option(s2cObservers.remove(gameId)).foreach { obs =>
+ registry.get(gameId).foreach(_.engine.unsubscribe(obs))
+ }
+
+ private def handleC2sMessage(gameId: String, msg: String): Unit =
+ parseC2sMessage(msg) match
+ case Some(C2sMessage.Connected) => handleConnected(gameId)
+ case Some(C2sMessage.Move(uci)) => handleMove(gameId, uci)
+ case Some(C2sMessage.Ping) => ()
+ case None => ()
+
+ private def handleConnected(gameId: String): Unit =
+ registry.get(gameId).foreach { entry =>
+ val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
+ val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
+ redisson.getTopic(s2cTopicName(gameId)).publish(json)
+ }
+
+ private def handleMove(gameId: String, uci: String): Unit =
+ registry.get(gameId).foreach { entry =>
+ entry.engine.processUserInput(uci)
+ }
+
+ private def parseC2sMessage(msg: String): Option[C2sMessage] =
+ Try(objectMapper.readTree(msg)).toOption.flatMap { node =>
+ Option(node.get("type")).map(_.asText()).flatMap {
+ case "CONNECTED" => Some(C2sMessage.Connected)
+ case "MOVE" => Option(node.get("uci")).map(u => C2sMessage.Move(u.asText()))
+ case "PING" => Some(C2sMessage.Ping)
+ case _ => None
+ }
+ }
+
+ @PreDestroy
+ def cleanup(): Unit =
+ c2sListeners.forEach((gameId, listenerId) =>
+ redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)
+ )
+ s2cObservers.forEach((gameId, obs) =>
+ registry.get(gameId).foreach(_.engine.unsubscribe(obs))
+ )
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
new file mode 100644
index 0000000..ea4edfa
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameWritebackEventDto.scala
@@ -0,0 +1,26 @@
+package de.nowchess.chess.redis
+
+case class GameWritebackEventDto(
+ gameId: String,
+ fen: String,
+ pgn: String,
+ moveCount: Int,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ resigned: Boolean,
+ limitSeconds: Option[Int],
+ incrementSeconds: Option[Int],
+ daysPerMove: Option[Int],
+ whiteRemainingMs: Option[Long],
+ blackRemainingMs: Option[Long],
+ incrementMs: Option[Long],
+ clockLastTickAt: Option[Long],
+ clockMoveDeadline: Option[Long],
+ clockActiveColor: Option[String],
+ pendingDrawOffer: Option[String],
+ redoStack: List[String] = Nil,
+ pendingTakebackRequest: Option[String] = None,
+)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameCacheDto.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameCacheDto.scala
new file mode 100644
index 0000000..57b6b58
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameCacheDto.scala
@@ -0,0 +1,25 @@
+package de.nowchess.chess.registry
+
+case class GameCacheDto(
+ gameId: String,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ pgn: String,
+ fen: String,
+ resigned: Boolean,
+ limitSeconds: Option[Int],
+ incrementSeconds: Option[Int],
+ daysPerMove: Option[Int],
+ whiteRemainingMs: Option[Long],
+ blackRemainingMs: Option[Long],
+ incrementMs: Option[Long],
+ clockLastTickAt: Option[Long],
+ clockMoveDeadline: Option[Long],
+ clockActiveColor: Option[String],
+ pendingDrawOffer: Option[String],
+ redoStack: List[String] = Nil,
+ pendingTakebackRequest: Option[String] = None,
+)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala
deleted file mode 100644
index 61668d4..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.nowchess.chess.registry
-
-import jakarta.enterprise.context.ApplicationScoped
-import java.security.SecureRandom
-import java.util.concurrent.ConcurrentHashMap
-
-@ApplicationScoped
-class GameRegistryImpl extends GameRegistry:
- private val games = ConcurrentHashMap[String, GameEntry]()
- private val rng = new SecureRandom()
-
- def store(entry: GameEntry): Unit =
- games.put(entry.gameId, entry)
-
- def get(gameId: String): Option[GameEntry] =
- Option(games.get(gameId))
-
- def update(entry: GameEntry): Unit =
- games.put(entry.gameId, entry)
-
- def generateId(): String =
- val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
- Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala
new file mode 100644
index 0000000..23c1db9
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala
@@ -0,0 +1,202 @@
+package de.nowchess.chess.registry
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.board.Color
+import de.nowchess.api.game.{ClockState, CorrespondenceClockState, GameContext, GameMode, LiveClockState, TimeControl}
+import de.nowchess.api.move.Move
+import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.client.{GameRecordDto, StoreServiceClient}
+import de.nowchess.chess.controller.Parser
+import de.nowchess.chess.engine.GameEngine
+import de.nowchess.chess.grpc.RuleSetGrpcAdapter
+import de.nowchess.chess.config.RedisConfig
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.chess.resource.GameDtoMapper
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.redisson.api.RedissonClient
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.nio.charset.StandardCharsets
+import java.security.{MessageDigest, SecureRandom}
+import java.time.Instant
+import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
+
+@ApplicationScoped
+class RedisGameRegistry extends GameRegistry:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var redisson: RedissonClient = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var ioClient: IoGrpcClientWrapper = uninitialized
+ @Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
+ @Inject @RestClient var storeClient: StoreServiceClient = uninitialized
+ // scalafix:on
+
+ private val localEngines = ConcurrentHashMap[String, GameEntry]()
+ private val rng = new SecureRandom()
+
+ private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
+ private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
+
+ def generateId(): String =
+ val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString
+
+ def store(entry: GameEntry): Unit =
+ localEngines.put(entry.gameId, entry)
+ val combined = ioClient.exportCombined(entry.engine.context)
+ bucket(entry.gameId).set(toJson(entry, combined.fen, combined.pgn), 30, TimeUnit.MINUTES)
+
+ def get(gameId: String): Option[GameEntry] =
+ Option(localEngines.get(gameId)) match
+ case Some(localEntry) =>
+ readRedisDto(gameId).flatMap(dto => Try(reconstruct(dto)).toOption) match
+ case Some(redisEntry) if !sameSnapshot(localEntry, redisEntry) =>
+ localEngines.put(gameId, redisEntry)
+ Some(redisEntry)
+ case _ => Some(localEntry)
+ case None => fromRedis(gameId).orElse(fromDb(gameId))
+
+ def update(entry: GameEntry): Unit =
+ localEngines.put(entry.gameId, entry)
+ val combined = ioClient.exportCombined(entry.engine.context)
+ bucket(entry.gameId).set(toJson(entry, combined.fen, combined.pgn), 30, TimeUnit.MINUTES)
+
+ private def readRedisDto(gameId: String): Option[GameCacheDto] =
+ Try(Option(bucket(gameId).get())).toOption.flatten.flatMap { json =>
+ Try(objectMapper.readValue(json, classOf[GameCacheDto])).toOption
+ }
+
+ private def fromRedis(gameId: String): Option[GameEntry] =
+ readRedisDto(gameId)
+ .flatMap(dto => Try(reconstruct(dto)).toOption)
+ .map { entry =>
+ localEngines.put(gameId, entry)
+ entry
+ }
+
+ private def fromDb(gameId: String): Option[GameEntry] =
+ Try {
+ val record = storeClient.getGame(gameId)
+ val dto = GameCacheDto(
+ gameId = record.gameId,
+ fen = record.fen,
+ pgn = record.pgn,
+ whiteId = record.whiteId,
+ whiteName = record.whiteName,
+ blackId = record.blackId,
+ blackName = record.blackName,
+ mode = record.mode,
+ resigned = record.resigned,
+ limitSeconds = Option(record.limitSeconds).map(_.intValue),
+ incrementSeconds = Option(record.incrementSeconds).map(_.intValue),
+ daysPerMove = Option(record.daysPerMove).map(_.intValue),
+ whiteRemainingMs = Option(record.whiteRemainingMs).map(_.longValue),
+ blackRemainingMs = Option(record.blackRemainingMs).map(_.longValue),
+ incrementMs = Option(record.incrementMs).map(_.longValue),
+ clockLastTickAt = Option(record.clockLastTickAt).map(_.longValue),
+ clockMoveDeadline = Option(record.clockMoveDeadline).map(_.longValue),
+ clockActiveColor = Option(record.clockActiveColor),
+ pendingDrawOffer = Option(record.pendingDrawOffer),
+ )
+ (dto, reconstruct(dto))
+ }.toOption
+ .map { case (dto, entry) =>
+ localEngines.put(gameId, entry)
+ bucket(gameId).set(objectMapper.writeValueAsString(dto), 30, TimeUnit.MINUTES)
+ entry
+ }
+
+ private def reconstruct(dto: GameCacheDto): GameEntry =
+ val ctx = if dto.pgn.nonEmpty then ioClient.importPgn(dto.pgn) else GameContext.initial
+ val tc = (dto.limitSeconds, dto.daysPerMove) match
+ case (Some(l), _) => TimeControl.Clock(l, dto.incrementSeconds.getOrElse(0))
+ case (None, Some(d)) => TimeControl.Correspondence(d)
+ case _ => TimeControl.Unlimited
+ val toColor: String => Color = s => if s == "white" then Color.White else Color.Black
+ val restoredClock: Option[ClockState] =
+ dto.clockLastTickAt.map { tick =>
+ LiveClockState(
+ whiteRemainingMs = dto.whiteRemainingMs.get,
+ blackRemainingMs = dto.blackRemainingMs.get,
+ incrementMs = dto.incrementMs.get,
+ lastTickAt = Instant.ofEpochMilli(tick),
+ activeColor = toColor(dto.clockActiveColor.get),
+ )
+ }.orElse {
+ dto.clockMoveDeadline.map { deadline =>
+ CorrespondenceClockState(
+ moveDeadline = Instant.ofEpochMilli(deadline),
+ daysPerMove = dto.daysPerMove.get,
+ activeColor = toColor(dto.clockActiveColor.get),
+ )
+ }
+ }
+ val restoredDrawOffer = dto.pendingDrawOffer.map(toColor)
+ val restoredTakebackRequest = dto.pendingTakebackRequest.map(toColor)
+ val redoMoves = dto.redoStack.flatMap { uci =>
+ Parser.parseMove(uci).flatMap { case (from, to, pp) =>
+ ruleSetAdapter.legalMoves(ctx)(from)
+ .find(m => m.to == to && (pp.isEmpty || m.moveType == de.nowchess.api.move.MoveType.Promotion(pp.get)))
+ }
+ }
+ val engine = GameEngine(
+ initialContext = ctx,
+ ruleSet = ruleSetAdapter,
+ timeControl = tc,
+ initialClockState = restoredClock,
+ initialDrawOffer = restoredDrawOffer,
+ initialRedoStack = redoMoves,
+ initialTakebackRequest = restoredTakebackRequest,
+ )
+ GameEntry(
+ gameId = dto.gameId,
+ engine = engine,
+ white = PlayerInfo(PlayerId(dto.whiteId), dto.whiteName),
+ black = PlayerInfo(PlayerId(dto.blackId), dto.blackName),
+ resigned = dto.resigned,
+ mode = if dto.mode == "Authenticated" then GameMode.Authenticated else GameMode.Open,
+ )
+
+ private def toJson(entry: GameEntry, fen: String, pgn: String): String =
+ objectMapper.writeValueAsString(toDto(entry, fen, pgn))
+
+ private def toDto(entry: GameEntry, fen: String, pgn: String): GameCacheDto =
+ val clock = entry.engine.currentClockState
+ GameCacheDto(
+ gameId = entry.gameId,
+ whiteId = entry.white.id.value,
+ whiteName = entry.white.displayName,
+ blackId = entry.black.id.value,
+ blackName = entry.black.displayName,
+ mode = entry.mode.toString,
+ pgn = pgn,
+ fen = fen,
+ resigned = entry.resigned,
+ limitSeconds = entry.engine.timeControl match { case TimeControl.Clock(l, _) => Some(l); case _ => None },
+ incrementSeconds = entry.engine.timeControl match { case TimeControl.Clock(_, i) => Some(i); case _ => None },
+ daysPerMove = entry.engine.timeControl match { case TimeControl.Correspondence(d) => Some(d); case _ => None },
+ whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
+ blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
+ incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
+ clockLastTickAt = clock.collect { case c: LiveClockState => c.lastTickAt.toEpochMilli },
+ clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
+ clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
+ pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
+ redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
+ pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
+ )
+
+ private def sameSnapshot(localEntry: GameEntry, redisEntry: GameEntry): Boolean =
+ entryHash(localEntry).exists(localHash => entryHash(redisEntry).contains(localHash))
+
+ private def entryHash(entry: GameEntry): Option[String] =
+ Try {
+ val combined = ioClient.exportCombined(entry.engine.context)
+ val canonicalJson = objectMapper.writeValueAsString(toDto(entry, combined.fen, combined.pgn))
+ val digest = MessageDigest.getInstance("SHA-256").digest(canonicalJson.getBytes(StandardCharsets.UTF_8))
+ digest.map("%02x".format(_)).mkString
+ }.toOption
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
new file mode 100644
index 0000000..1d038c9
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala
@@ -0,0 +1,73 @@
+package de.nowchess.chess.resource
+
+import de.nowchess.api.board.Color
+import de.nowchess.api.dto.*
+import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState, WinReason}
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.api.player.PlayerInfo
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.chess.registry.GameEntry
+import java.time.Instant
+
+object GameDtoMapper:
+
+ def statusOf(entry: GameEntry): String =
+ if entry.engine.pendingTakebackRequestBy.isDefined then "takebackRequested"
+ else if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
+ else
+ val ctx = entry.engine.context
+ ctx.result match
+ case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
+ case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
+ case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
+ case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
+ case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
+ case Some(GameResult.Draw(_)) => "draw"
+ case None =>
+ if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
+ else if entry.engine.ruleSet.isCheck(ctx) then "check"
+ else "started"
+
+ def moveToUci(move: Move): String =
+ val base = s"${move.from}${move.to}"
+ move.moveType match
+ case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
+ case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
+ case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
+ case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
+ case _ => base
+
+ def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
+ PlayerInfoDto(info.id.value, info.displayName)
+
+ def toClockDto(entry: GameEntry): Option[ClockDto] =
+ val now = Instant.now()
+ entry.engine.currentClockState.map {
+ case cs: LiveClockState =>
+ ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
+ case cs: CorrespondenceClockState =>
+ val remaining = cs.remainingMs(cs.activeColor, now)
+ ClockDto(
+ whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
+ blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
+ )
+ }
+
+ def toGameStateDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameStateDto =
+ val ctx = entry.engine.context
+ val exported = ioClient.exportCombined(ctx)
+ GameStateDto(
+ fen = exported.fen,
+ pgn = exported.pgn,
+ turn = ctx.turn.label.toLowerCase,
+ status = statusOf(entry),
+ winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
+ moves = ctx.moves.map(moveToUci),
+ undoAvailable = entry.engine.canUndo,
+ redoAvailable = entry.engine.canRedo,
+ clock = toClockDto(entry),
+ takebackRequestedBy = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
+ )
+
+ def toGameFullDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameFullDto =
+ GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry, ioClient))
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 779a310..6e54051 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
@@ -22,6 +22,7 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
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 jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -51,6 +52,9 @@ class GameResource:
@Inject
var jwt: JsonWebToken = uninitialized
+
+ @Inject
+ var subscriberManager: GameRedisSubscriberManager = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
@@ -79,31 +83,6 @@ class GameResource:
// ── mapping ──────────────────────────────────────────────────────────────
- private def statusOf(entry: GameEntry): String =
- if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
- else
- val ctx = entry.engine.context
- ctx.result match
- case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
- case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
- case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
- case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
- case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
- case Some(GameResult.Draw(_)) => "draw"
- case None =>
- if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
- else if entry.engine.ruleSet.isCheck(ctx) then "check"
- else "started"
-
- private def moveToUci(move: Move): String =
- val base = s"${move.from}${move.to}"
- move.moveType match
- case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
- case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
- case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
- case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
- case _ => base
-
private def toLegalMoveDto(move: Move): LegalMoveDto =
val (moveTypeStr, promotionStr) = move.moveType match
case MoveType.Normal(false) => ("normal", None)
@@ -115,41 +94,7 @@ class GameResource:
case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
- LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveTypeStr, promotionStr)
-
- private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
- PlayerInfoDto(info.id.value, info.displayName)
-
- private def toClockDto(entry: GameEntry): Option[ClockDto] =
- val now = Instant.now()
- entry.engine.currentClockState.map {
- case cs: LiveClockState =>
- ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
- case cs: CorrespondenceClockState =>
- val remaining = cs.remainingMs(cs.activeColor, now)
- ClockDto(
- whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
- blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
- )
- }
-
- private def toGameStateDto(entry: GameEntry): GameStateDto =
- val ctx = entry.engine.context
- val exported = ioClient.exportCombined(ctx)
- GameStateDto(
- fen = exported.fen,
- pgn = exported.pgn,
- turn = ctx.turn.label.toLowerCase,
- status = statusOf(entry),
- winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
- moves = ctx.moves.map(moveToUci),
- undoAvailable = entry.engine.canUndo,
- redoAvailable = entry.engine.canRedo,
- clock = toClockDto(entry),
- )
-
- private def toGameFullDto(entry: GameEntry): GameFullDto =
- GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
+ LegalMoveDto(move.from.toString, move.to.toString, GameDtoMapper.moveToUci(move), moveTypeStr, promotionStr)
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
@@ -213,15 +158,16 @@ class GameResource:
val mode = req.mode.getOrElse(GameMode.Open)
val entry = newEntry(GameContext.initial, white, black, tc, mode)
registry.store(entry)
+ subscriberManager.subscribeGame(entry.gameId)
println(s"Created game ${entry.gameId}")
- created(toGameFullDto(entry))
+ created(GameDtoMapper.toGameFullDto(entry, ioClient))
@GET
@Path("/{gameId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
- ok(toGameFullDto(entry))
+ ok(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST
@Path("/{gameId}/resign")
@@ -244,7 +190,8 @@ class GameResource:
if Parser.parseMove(uci).isEmpty then
throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
- ok(toGameStateDto(entry))
+ registry.update(entry)
+ ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@GET
@Path("/{gameId}/moves")
@@ -271,7 +218,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
entry.engine.undo()
- ok(toGameStateDto(entry))
+ registry.update(entry)
+ ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@POST
@Path("/{gameId}/redo")
@@ -280,7 +228,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
entry.engine.redo()
- ok(toGameStateDto(entry))
+ registry.update(entry)
+ ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@POST
@Path("/{gameId}/draw/{action}")
@@ -293,12 +242,28 @@ class GameResource:
assertGameNotOver(entry)
val color = colorOf(entry)
action match
- case "offer" => entry.engine.offerDraw(color); ok(OkResponseDto())
- case "accept" => entry.engine.acceptDraw(color); ok(OkResponseDto())
- case "decline" => entry.engine.declineDraw(color); ok(OkResponseDto())
- case "claim" => entry.engine.claimDraw(); ok(OkResponseDto())
+ case "offer" => entry.engine.offerDraw(color); registry.update(entry); ok(OkResponseDto())
+ case "accept" => entry.engine.acceptDraw(color); registry.update(entry); ok(OkResponseDto())
+ case "decline" => entry.engine.declineDraw(color); registry.update(entry); ok(OkResponseDto())
+ case "claim" => entry.engine.claimDraw(); registry.update(entry); ok(OkResponseDto())
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
+ @POST
+ @Path("/{gameId}/takeback/{action}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def takebackAction(
+ @PathParam("gameId") gameId: String,
+ @PathParam("action") action: String,
+ ): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ assertGameNotOver(entry)
+ val color = colorOf(entry)
+ action match
+ case "request" => entry.engine.requestTakeback(color); registry.update(entry); ok(OkResponseDto())
+ case "accept" => entry.engine.acceptTakeback(color); registry.update(entry); ok(GameDtoMapper.toGameStateDto(entry, ioClient))
+ case "decline" => entry.engine.declineTakeback(color); registry.update(entry); ok(OkResponseDto())
+ case _ => throw BadRequestException("INVALID_ACTION", s"Unknown takeback action: $action", Some("action"))
+
@POST
@Path("/import/fen")
@Consumes(Array(MediaType.APPLICATION_JSON))
@@ -310,7 +275,8 @@ class GameResource:
val tc = toTimeControl(body.timeControl)
val entry = newEntry(ctx, white, black, tc)
registry.store(entry)
- created(toGameFullDto(entry))
+ subscriberManager.subscribeGame(entry.gameId)
+ created(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST
@Path("/import/pgn")
@@ -320,7 +286,8 @@ class GameResource:
val ctx = ioClient.importPgn(body.pgn)
val entry = newEntry(ctx, DefaultWhite, DefaultBlack)
registry.store(entry)
- created(toGameFullDto(entry))
+ subscriberManager.subscribeGame(entry.gameId)
+ created(GameDtoMapper.toGameFullDto(entry, ioClient))
@GET
@Path("/{gameId}/export/fen")
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala
deleted file mode 100644
index 276b4dd..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala
+++ /dev/null
@@ -1,132 +0,0 @@
-package de.nowchess.chess.resource
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import de.nowchess.api.board.Color
-import de.nowchess.api.dto.*
-import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState}
-import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
-import de.nowchess.api.player.PlayerInfo
-import de.nowchess.chess.client.IoServiceClient
-import de.nowchess.chess.observer.*
-import de.nowchess.chess.registry.{GameEntry, GameRegistry}
-import io.quarkus.websockets.next.*
-import jakarta.inject.Inject
-import org.eclipse.microprofile.rest.client.inject.RestClient
-
-import java.time.Instant
-import java.util.concurrent.ConcurrentHashMap
-import scala.compiletime.uninitialized
-
-@WebSocket(path = "/api/board/game/{gameId}/ws")
-class GameWebSocketResource:
-
- // scalafix:off DisableSyntax.var
- @Inject
- var registry: GameRegistry = uninitialized
-
- @Inject
- var objectMapper: ObjectMapper = uninitialized
-
- @Inject
- @RestClient
- var ioClient: IoServiceClient = uninitialized
- // scalafix:on DisableSyntax.var
-
- private val connectionObservers = new ConcurrentHashMap[String, (String, Observer)]()
-
- @OnOpen
- def onOpen(connection: WebSocketConnection): Unit =
- val gameId = connection.pathParam("gameId")
- registry.get(gameId) match
- case None =>
- val err = ErrorEventDto(ApiErrorDto("GAME_NOT_FOUND", s"Game $gameId not found", None))
- connection
- .sendText(objectMapper.writeValueAsString(err))
- .flatMap(_ => connection.close())
- .subscribe()
- .`with`(_ => (), _ => ())
- case Some(entry) =>
- val initial = objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry)))
- val obs = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- registry.get(gameId).foreach { updated =>
- connection
- .sendText(objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))))
- .subscribe()
- .`with`(_ => (), _ => ())
- }
- connection
- .sendText(initial)
- .subscribe()
- .`with`(
- _ => {
- connectionObservers.put(connection.id(), (gameId, obs))
- entry.engine.subscribe(obs)
- },
- _ => (),
- )
-
- @OnClose
- def onClose(connection: WebSocketConnection): Unit =
- Option(connectionObservers.remove(connection.id())).foreach { case (gameId, obs) =>
- registry.get(gameId).foreach(_.engine.unsubscribe(obs))
- }
-
- private def statusOf(entry: GameEntry): String =
- if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
- else
- val ctx = entry.engine.context
- ctx.result match
- case Some(GameResult.Win(_, _)) =>
- if entry.resigned then "resign"
- else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate"
- else "timeout"
- case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
- case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
- case Some(GameResult.Draw(_)) => "draw"
- case None =>
- if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
- else if entry.engine.ruleSet.isCheck(ctx) then "check"
- else "started"
-
- private def moveToUci(move: Move): String =
- val base = s"${move.from}${move.to}"
- move.moveType match
- case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
- case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
- case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
- case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
- case _ => base
-
- private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
- PlayerInfoDto(info.id.value, info.displayName)
-
- private def toClockDto(entry: GameEntry): Option[ClockDto] =
- val now = Instant.now()
- entry.engine.currentClockState.map {
- case cs: LiveClockState =>
- ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
- case cs: CorrespondenceClockState =>
- val remaining = cs.remainingMs(cs.activeColor, now)
- ClockDto(
- whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
- blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
- )
- }
-
- private def toGameStateDto(entry: GameEntry): GameStateDto =
- val ctx = entry.engine.context
- GameStateDto(
- fen = ioClient.exportFen(ctx),
- pgn = ioClient.exportPgn(ctx),
- turn = ctx.turn.label.toLowerCase,
- status = statusOf(entry),
- winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
- moves = ctx.moves.map(moveToUci),
- undoAvailable = entry.engine.canUndo,
- redoAvailable = entry.engine.canRedo,
- clock = toClockDto(entry),
- )
-
- private def toGameFullDto(entry: GameEntry): GameFullDto =
- GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
diff --git a/modules/core/src/test/resources/application.yml b/modules/core/src/test/resources/application.yml
index 23a3682..22c524c 100644
--- a/modules/core/src/test/resources/application.yml
+++ b/modules/core/src/test/resources/application.yml
@@ -7,3 +7,9 @@ quarkus:
io-grpc:
host: localhost
port: 9081
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: test-core
diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts
index 4833529..1f5bb0f 100644
--- a/modules/io/build.gradle.kts
+++ b/modules/io/build.gradle.kts
@@ -115,3 +115,15 @@ tasks.reportScoverage {
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+
+tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ }
+ }
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+}
diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts
index d568fe4..4a6adea 100644
--- a/modules/rule/build.gradle.kts
+++ b/modules/rule/build.gradle.kts
@@ -115,3 +115,15 @@ tasks.reportScoverage {
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+
+tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ }
+ }
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+}
diff --git a/modules/store/build.gradle.kts b/modules/store/build.gradle.kts
new file mode 100644
index 0000000..098f720
--- /dev/null
+++ b/modules/store/build.gradle.kts
@@ -0,0 +1,108 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ id("io.quarkus")
+}
+
+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 {
+
+ runtimeOnly("io.quarkus:quarkus-jdbc-h2")
+
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+
+ implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-hibernate-orm-panache")
+ implementation("io.quarkus:quarkus-jdbc-postgresql")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ 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")
+ testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
+ testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+ testImplementation("io.quarkus:quarkus-junit5")
+ testImplementation("io.quarkus:quarkus-junit5-mockito")
+ testImplementation("io.rest-assured:rest-assured")
+ testImplementation("io.quarkus:quarkus-jdbc-h2")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+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
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging {
+ events("passed", "skipped", "failed")
+ showStandardStreams = true
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage {
+ dependsOn(tasks.test)
+}
+
+tasks.jar {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+}
diff --git a/modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala b/modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala
new file mode 100644
index 0000000..032cfa2
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala
@@ -0,0 +1,16 @@
+package de.nowchess.store.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedisConfig:
+ @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
diff --git a/modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala b/modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala
new file mode 100644
index 0000000..e2b81ae
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala
@@ -0,0 +1,29 @@
+package de.nowchess.store.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:
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+
+ @Produces
+ @ApplicationScoped
+ def redissonClient(): RedissonClient =
+ val config = new Config()
+ config.useSingleServer()
+ .setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
+ .setConnectionMinimumIdleSize(1)
+ .setConnectTimeout(500)
+ Redisson.create(config)
+
+ @PreDestroy
+ def close(client: RedissonClient): Unit =
+ client.shutdown()
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
new file mode 100644
index 0000000..3e4f76c
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala
@@ -0,0 +1,82 @@
+package de.nowchess.store.domain
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase
+import jakarta.persistence.*
+import scala.compiletime.uninitialized
+import java.time.Instant
+
+@Entity
+@Table(name = "game_records")
+class GameRecord extends PanacheEntityBase:
+ // scalafix:off DisableSyntax.var
+ @Id
+ @Column(nullable = false)
+ var gameId: String = uninitialized
+
+ @Column(nullable = false, columnDefinition = "TEXT")
+ var fen: String = uninitialized
+
+ @Column(nullable = false, columnDefinition = "TEXT")
+ var pgn: String = uninitialized
+
+ @Column(nullable = false)
+ var moveCount: Int = 0
+
+ @Column(nullable = false)
+ var createdAt: Instant = uninitialized
+
+ @Column(nullable = false)
+ var updatedAt: Instant = uninitialized
+
+ // Player info
+ @Column(nullable = false)
+ var whiteId: String = uninitialized
+
+ @Column(nullable = false)
+ var whiteName: String = uninitialized
+
+ @Column(nullable = false)
+ var blackId: String = uninitialized
+
+ @Column(nullable = false)
+ var blackName: String = uninitialized
+
+ @Column(nullable = false)
+ var mode: String = uninitialized
+
+ // Time control
+ @Column
+ var limitSeconds: java.lang.Integer = uninitialized
+
+ @Column
+ var incrementSeconds: java.lang.Integer = uninitialized
+
+ @Column
+ var daysPerMove: java.lang.Integer = uninitialized
+
+ // Clock state
+ @Column
+ var whiteRemainingMs: java.lang.Long = uninitialized
+
+ @Column
+ var blackRemainingMs: java.lang.Long = uninitialized
+
+ @Column
+ var incrementMs: java.lang.Long = uninitialized
+
+ @Column
+ var clockLastTickAt: java.lang.Long = uninitialized
+
+ @Column
+ var clockMoveDeadline: java.lang.Long = uninitialized
+
+ @Column
+ var clockActiveColor: String = uninitialized
+
+ // Game meta
+ @Column(nullable = false)
+ var resigned: Boolean = false
+
+ @Column
+ var pendingDrawOffer: 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
new file mode 100644
index 0000000..47dda46
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackEventDto.scala
@@ -0,0 +1,24 @@
+package de.nowchess.store.redis
+
+case class GameWritebackEventDto(
+ gameId: String,
+ fen: String,
+ pgn: String,
+ moveCount: Int,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ resigned: Boolean,
+ limitSeconds: Option[Int],
+ incrementSeconds: Option[Int],
+ daysPerMove: Option[Int],
+ whiteRemainingMs: Option[Long],
+ blackRemainingMs: Option[Long],
+ incrementMs: Option[Long],
+ clockLastTickAt: Option[Long],
+ clockMoveDeadline: Option[Long],
+ clockActiveColor: Option[String],
+ pendingDrawOffer: Option[String],
+)
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
new file mode 100644
index 0000000..5dd395b
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala
@@ -0,0 +1,30 @@
+package de.nowchess.store.redis
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.store.service.GameWritebackService
+import jakarta.annotation.PostConstruct
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import org.redisson.api.listener.MessageListener
+import org.redisson.api.RedissonClient
+import scala.compiletime.uninitialized
+import scala.util.Try
+
+@ApplicationScoped
+class GameWritebackStreamListener:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var redisson: RedissonClient = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var writebackService: GameWritebackService = uninitialized
+ // scalafix:on
+
+ @PostConstruct
+ def startListening(): Unit =
+ 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)
+ )
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
new file mode 100644
index 0000000..506097d
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala
@@ -0,0 +1,23 @@
+package de.nowchess.store.repository
+
+import de.nowchess.store.domain.GameRecord
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.persistence.EntityManager
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class GameRecordRepository:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var em: EntityManager = uninitialized
+ // scalafix:on
+
+ def findByGameId(gameId: String): Option[GameRecord] =
+ Option(em.find(classOf[GameRecord], gameId))
+
+ def persist(record: GameRecord): Unit =
+ em.persist(record)
+
+ def merge(record: GameRecord): Unit =
+ em.merge(record)
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
new file mode 100644
index 0000000..20932b0
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala
@@ -0,0 +1,23 @@
+package de.nowchess.store.resource
+
+import de.nowchess.store.repository.GameRecordRepository
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+import scala.compiletime.uninitialized
+
+@Path("/game")
+@ApplicationScoped
+class StoreGameResource:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var repository: GameRecordRepository = uninitialized
+ // scalafix:on
+
+ @GET
+ @Path("/{gameId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getGame(@PathParam("gameId") gameId: String): Response =
+ repository.findByGameId(gameId)
+ .fold(Response.status(404).build())(r => Response.ok(r).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
new file mode 100644
index 0000000..a6929b1
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala
@@ -0,0 +1,69 @@
+package de.nowchess.store.service
+
+import de.nowchess.store.domain.GameRecord
+import de.nowchess.store.redis.GameWritebackEventDto
+import de.nowchess.store.repository.GameRecordRepository
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.transaction.Transactional
+import scala.compiletime.uninitialized
+import java.time.Instant
+
+@ApplicationScoped
+class GameWritebackService:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var repository: GameRecordRepository = uninitialized
+ // scalafix:on
+
+ @Transactional
+ def writeBack(event: GameWritebackEventDto): Unit =
+ repository.findByGameId(event.gameId) match
+ case None =>
+ val record = new GameRecord
+ record.gameId = event.gameId
+ record.fen = event.fen
+ record.pgn = event.pgn
+ record.moveCount = event.moveCount
+ record.whiteId = event.whiteId
+ record.whiteName = event.whiteName
+ record.blackId = event.blackId
+ record.blackName = event.blackName
+ record.mode = event.mode
+ record.resigned = event.resigned
+ record.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
+ record.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
+ record.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
+ record.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
+ record.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
+ record.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
+ record.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
+ record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
+ record.clockActiveColor = event.clockActiveColor.orNull
+ record.pendingDrawOffer = event.pendingDrawOffer.orNull
+ record.createdAt = Instant.now()
+ record.updatedAt = Instant.now()
+ repository.persist(record)
+ case Some(r) if event.moveCount > r.moveCount || event.pgn != r.pgn =>
+ r.fen = event.fen
+ r.pgn = event.pgn
+ r.moveCount = event.moveCount
+ r.whiteId = event.whiteId
+ r.whiteName = event.whiteName
+ r.blackId = event.blackId
+ r.blackName = event.blackName
+ r.mode = event.mode
+ r.resigned = event.resigned
+ r.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
+ r.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
+ r.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
+ r.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
+ r.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
+ r.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
+ r.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
+ r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
+ r.clockActiveColor = event.clockActiveColor.orNull
+ r.pendingDrawOffer = event.pendingDrawOffer.orNull
+ r.updatedAt = Instant.now()
+ repository.merge(r)
+ case _ => ()
diff --git a/modules/ws/build.gradle.kts b/modules/ws/build.gradle.kts
new file mode 100644
index 0000000..d072f4f
--- /dev/null
+++ b/modules/ws/build.gradle.kts
@@ -0,0 +1,92 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ id("io.quarkus")
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedFiles.set(scoverageExcluded)
+}
+
+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"]!!)
+ }
+ }
+
+ implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-websockets-next")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
+
+ testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
+ testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+ testImplementation("io.quarkus:quarkus-junit5")
+ testImplementation("io.quarkus:quarkus-junit5-mockito")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
+}
+
+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 }
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging { events("passed", "skipped", "failed") }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage { dependsOn(tasks.test) }
+tasks.jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
diff --git a/modules/ws/src/main/resources/application.yml b/modules/ws/src/main/resources/application.yml
new file mode 100644
index 0000000..ebafb69
--- /dev/null
+++ b/modules/ws/src/main/resources/application.yml
@@ -0,0 +1,28 @@
+quarkus:
+ http:
+ port: 8084
+ application:
+ name: nowchess-ws
+ grpc:
+ server:
+ use-separate-server: false
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%dev":
+ nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%deployed":
+ nowchess:
+ redis:
+ host: ${REDIS_HOST}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala b/modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala
new file mode 100644
index 0000000..360049e
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala
@@ -0,0 +1,18 @@
+package de.nowchess.ws.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/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala b/modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala
new file mode 100644
index 0000000..1fc4a17
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala
@@ -0,0 +1,35 @@
+package de.nowchess.ws.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/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala b/modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala
new file mode 100644
index 0000000..741dd6b
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala
@@ -0,0 +1,52 @@
+package de.nowchess.ws.resource
+
+import de.nowchess.ws.config.RedisConfig
+import io.quarkus.websockets.next.*
+import jakarta.inject.Inject
+import org.redisson.api.listener.MessageListener
+import org.redisson.api.RedissonClient
+import scala.compiletime.uninitialized
+import java.util.concurrent.ConcurrentHashMap
+
+@WebSocket(path = "/api/board/game/{gameId}/ws")
+class GameWebSocketResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var redisson: RedissonClient = uninitialized
+
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val listenerIds = new ConcurrentHashMap[String, (String, Int)]()
+
+ private def s2cTopic(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:s2c"
+
+ private def c2sTopic(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:c2s"
+
+ @OnOpen
+ def onOpen(connection: WebSocketConnection): Unit =
+ val gameId = connection.pathParam("gameId")
+ val topic = redisson.getTopic(s2cTopic(gameId))
+ val listenerId = topic.addListener(classOf[String], new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
+ )
+ listenerIds.put(connection.id(), (gameId, listenerId))
+ val connectedMsg = s"""{"type":"CONNECTED","gameId":"$gameId"}"""
+ redisson.getTopic(c2sTopic(gameId)).publish(connectedMsg)
+
+ @OnTextMessage
+ def onTextMessage(connection: WebSocketConnection, message: String): Unit =
+ Option(listenerIds.get(connection.id())).foreach { case (gameId, _) =>
+ redisson.getTopic(c2sTopic(gameId)).publish(message)
+ }
+
+ @OnClose
+ def onClose(connection: WebSocketConnection): Unit =
+ Option(listenerIds.remove(connection.id())).foreach { case (gameId, listenerId) =>
+ redisson.getTopic(s2cTopic(gameId)).removeListener(listenerId)
+ }
diff --git a/modules/ws/src/test/resources/application.yml b/modules/ws/src/test/resources/application.yml
new file mode 100644
index 0000000..ce0a06b
--- /dev/null
+++ b/modules/ws/src/test/resources/application.yml
@@ -0,0 +1,11 @@
+quarkus:
+ http:
+ port: 8084
+ application:
+ name: nowchess-ws
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: test
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 19f1da8..4d39f7a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,4 +21,6 @@ include(
"modules:rule",
"modules:bot",
"modules:account",
+ "modules:ws",
+ "modules:store",
)
\ No newline at end of file