feat(redis): implement Redis integration for game state management and websocket communication

This commit is contained in:
2026-04-26 00:13:35 +02:00
parent ec09a1bdb9
commit 83f84371be
48 changed files with 1475 additions and 427 deletions
+1
View File
@@ -17,6 +17,7 @@
<option value="$PROJECT_DIR$/modules/io" /> <option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/json" /> <option value="$PROJECT_DIR$/modules/json" />
<option value="$PROJECT_DIR$/modules/rule" /> <option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/ws" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
</profile> </profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test"> <profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
<parameters> <parameters>
+1 -1
View File
@@ -11,5 +11,5 @@ get {
} }
vars:pre-request { vars:pre-request {
gameId: Yg200tOF gameId: j0nPtcjl
} }
+15 -3
View File
@@ -11,12 +11,24 @@ ws {
} }
body:ws { body:ws {
name: message 1 name: move
content: ''' content: '''
{} {
"type": "MOVE",
"uci": "b1c3"
}
'''
}
body:ws {
name: ping
content: '''
{
"type": "PING"
}
''' '''
} }
vars:pre-request { vars:pre-request {
gameId: uWm99efJ gameId: j0nPtcjl
} }
+1 -1
View File
@@ -1,5 +1,5 @@
vars { vars {
baseUrl: http://localhost:8080 baseUrl: http://localhost:8080
wsBaseUrl: ws://localhost:8080 wsBaseUrl: ws://localhost:8084
ioBaseUrl: http://localhost:8081 ioBaseUrl: http://localhost:8081
} }
+7 -2
View File
@@ -46,7 +46,11 @@ val coverageExclusions = listOf(
// AccountResource / ChallengeResource — REST integration layer; @QuarkusTest not instrumented by Scoverage // AccountResource / ChallengeResource — REST integration layer; @QuarkusTest not instrumented by Scoverage
"**/account/src/main/scala/de/nowchess/account/resource/**", "**/account/src/main/scala/de/nowchess/account/resource/**",
// JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic // 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). // 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", "SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2", "FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.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 extra["VERSIONS"] = versions
@@ -30,8 +30,10 @@ class AlreadyLoggedInFilter extends ContainerRequestFilter:
) )
private def isAuthenticated: Boolean = private def isAuthenticated: Boolean =
// scalafix:off DisableSyntax.null
try jwt.getName != null try jwt.getName != null
catch case _ => false catch case _ => false
// scalafix:on DisableSyntax.null
private def isProtectedEndpoint(path: String, method: String): Boolean = private def isProtectedEndpoint(path: String, method: String): Boolean =
(path.contains("/api/account") || path.contains("/account")) && (path.contains("/api/account") || path.contains("/account")) &&
@@ -10,4 +10,5 @@ final case class GameStateDto(
undoAvailable: Boolean, undoAvailable: Boolean,
redoAvailable: Boolean, redoAvailable: Boolean,
clock: Option[ClockDto], clock: Option[ClockDto],
takebackRequestedBy: Option[String] = None,
) )
+16 -1
View File
@@ -70,7 +70,7 @@ dependencies {
implementation("io.quarkus:quarkus-websockets-next") implementation("io.quarkus:quarkus-websockets-next")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") 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:io"))
testImplementation(project(":modules:rule")) testImplementation(project(":modules:rule"))
@@ -124,3 +124,18 @@ tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE 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"))
}
@@ -14,6 +14,12 @@ quarkus:
server: server:
use-separate-server: false use-separate-server: false
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
"%dev": "%dev":
mp: mp:
jwt: jwt:
@@ -41,6 +47,8 @@ quarkus:
url: http://localhost:8081 url: http://localhost:8081
rule-service: rule-service:
url: http://localhost:8082 url: http://localhost:8082
store-service:
url: http://localhost:8085
"%deployed": "%deployed":
mp: mp:
@@ -69,3 +77,10 @@ quarkus:
url: ${IO_SERVICE_URL} url: ${IO_SERVICE_URL}
rule-service: rule-service:
url: ${RULE_SERVICE_URL} url: ${RULE_SERVICE_URL}
store-service:
url: ${STORE_SERVICE_URL}
nowchess:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
@@ -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,
)
@@ -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
@@ -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"
@@ -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
}
@@ -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
@@ -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())
@@ -18,7 +18,6 @@ import de.nowchess.api.game.{
import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.api.error.GameError import de.nowchess.api.error.GameError
import de.nowchess.api.game.WinReason.{Checkmate, Resignation} import de.nowchess.api.game.WinReason.{Checkmate, Resignation}
import de.nowchess.api.io.{GameContextExport, GameContextImport} import de.nowchess.api.io.{GameContextExport, GameContextImport}
@@ -39,6 +38,10 @@ class GameEngine(
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
), ),
val timeControl: TimeControl = TimeControl.Unlimited, val timeControl: TimeControl = TimeControl.Unlimited,
initialClockState: Option[ClockState] = None,
initialDrawOffer: Option[Color] = None,
initialRedoStack: List[Move] = Nil,
initialTakebackRequest: Option[Color] = None,
) extends Observable: ) extends Observable:
// Ensure that initialBoard is set correctly for threefold repetition detection // Ensure that initialBoard is set correctly for threefold repetition detection
private val contextWithInitialBoard = private val contextWithInitialBoard =
@@ -48,15 +51,20 @@ class GameEngine(
@SuppressWarnings(Array("DisableSyntax.var")) @SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard private var currentContext: GameContext = contextWithInitialBoard
@SuppressWarnings(Array("DisableSyntax.var")) @SuppressWarnings(Array("DisableSyntax.var"))
private var pendingDrawOffer: Option[Color] = None private var pendingDrawOffer: Option[Color] = initialDrawOffer
@SuppressWarnings(Array("DisableSyntax.var")) @SuppressWarnings(Array("DisableSyntax.var"))
private var clockState: Option[ClockState] = 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")) @SuppressWarnings(Array("DisableSyntax.var"))
private var scheduledCheck: Option[ScheduledFuture[?]] = None private var scheduledCheck: Option[ScheduledFuture[?]] = None
// One shared scheduler per engine; shut down with the game. // One shared scheduler per engine; shut down with the game.
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() 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. // Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
clockState.foreach(scheduleExpiryCheck) clockState.foreach(scheduleExpiryCheck)
@@ -71,13 +79,16 @@ class GameEngine(
def currentClockState: Option[ClockState] = synchronized(clockState) def currentClockState: Option[ClockState] = synchronized(clockState)
/** Check if undo is available. */ /** Check if undo is available. */
def canUndo: Boolean = synchronized(invoker.canUndo) def canUndo: Boolean = synchronized(currentContext.moves.nonEmpty)
/** Check if redo is available. */ /** 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). */ /** Get redo stack moves for inspection. */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history) 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 /** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
* GameEvent. * GameEvent.
@@ -162,8 +173,9 @@ class GameEngine(
else else
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation))) currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
pendingDrawOffer = None pendingDrawOffer = None
pendingTakebackRequest = None
stopClock() stopClock()
invoker.clear() redoStack = Nil
notifyObservers(ResignEvent(currentContext, color)) notifyObservers(ResignEvent(currentContext, color))
} }
@@ -193,8 +205,9 @@ class GameEngine(
case Some(_) => case Some(_) =>
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
pendingDrawOffer = None pendingDrawOffer = None
pendingTakebackRequest = None
stopClock() stopClock()
invoker.clear() redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement)) notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
} }
@@ -220,12 +233,12 @@ class GameEngine(
else if currentContext.halfMoveClock >= 100 then else if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
stopClock() stopClock()
invoker.clear() redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule)) notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
stopClock() stopClock()
invoker.clear() redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition)) notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed)) else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
} }
@@ -239,6 +252,8 @@ class GameEngine(
case Right(ctx) => case Right(ctx) =>
replayGame(ctx).map { _ => replayGame(ctx).map { _ =>
pendingDrawOffer = None pendingDrawOffer = None
pendingTakebackRequest = None
redoStack = Nil
stopClock() stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now()) clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(PgnLoadedEvent(currentContext)) notifyObservers(PgnLoadedEvent(currentContext))
@@ -248,7 +263,7 @@ class GameEngine(
private def replayGame(ctx: GameContext): Either[GameError, Unit] = private def replayGame(ctx: GameContext): Either[GameError, Unit] =
val savedContext = currentContext val savedContext = currentContext
currentContext = GameContext.initial currentContext = GameContext.initial
invoker.clear() redoStack = Nil
if ctx.moves.isEmpty then if ctx.moves.isEmpty then
currentContext = ctx.copy(initialBoard = ctx.board) currentContext = ctx.copy(initialBoard = ctx.board)
@@ -283,9 +298,10 @@ class GameEngine(
else newContext else newContext
currentContext = contextWithInitialBoard currentContext = contextWithInitialBoard
pendingDrawOffer = None pendingDrawOffer = None
pendingTakebackRequest = None
redoStack = Nil
stopClock() stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now()) clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
invoker.clear()
notifyObservers(BoardResetEvent(currentContext)) notifyObservers(BoardResetEvent(currentContext))
} }
@@ -293,9 +309,10 @@ class GameEngine(
def reset(): Unit = synchronized { def reset(): Unit = synchronized {
currentContext = GameContext.initial currentContext = GameContext.initial
pendingDrawOffer = None pendingDrawOffer = None
pendingTakebackRequest = None
redoStack = Nil
stopClock() stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now()) clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
invoker.clear()
notifyObservers(BoardResetEvent(currentContext)) notifyObservers(BoardResetEvent(currentContext))
} }
@@ -304,7 +321,7 @@ class GameEngine(
if currentContext.result.isEmpty then if currentContext.result.isEmpty then
currentContext = currentContext.withResult(Some(GameResult.Draw(reason))) currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
stopClock() stopClock()
invoker.clear() redoStack = Nil
notifyObservers(DrawEvent(currentContext, reason)) notifyObservers(DrawEvent(currentContext, reason))
} }
@@ -329,7 +346,8 @@ class GameEngine(
else GameResult.Win(flagged.opposite, WinReason.TimeControl) else GameResult.Win(flagged.opposite, WinReason.TimeControl)
currentContext = currentContext.withResult(Some(result)) currentContext = currentContext.withResult(Some(result))
pendingDrawOffer = None pendingDrawOffer = None
invoker.clear() pendingTakebackRequest = None
redoStack = Nil
notifyObservers(TimeFlagEvent(currentContext, flagged)) notifyObservers(TimeFlagEvent(currentContext, flagged))
private def scheduleExpiryCheck(cs: ClockState): Unit = private def scheduleExpiryCheck(cs: ClockState): Unit =
@@ -365,19 +383,15 @@ class GameEngine(
// ──── Private helpers ──── // ──── Private helpers ────
private def executeMove(move: Move): Unit = private def executeMove(move: Move): Unit =
if !isRedoing then
redoStack = Nil
pendingTakebackRequest = None
val contextBefore = currentContext val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move) val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move) val captured = computeCaptured(currentContext, move)
val notation = translateMoveToNotation(move, contextBefore.board)
val cmd = MoveCommand( currentContext = nextContext
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
advanceClock(contextBefore.turn) advanceClock(contextBefore.turn)
@@ -397,17 +411,17 @@ class GameEngine(
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate))) currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
cancelScheduled() cancelScheduled()
notifyObservers(CheckmateEvent(currentContext, winner)) notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear() redoStack = Nil
else if status.isStalemate then else if status.isStalemate then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
cancelScheduled() cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate)) notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear() redoStack = Nil
else if status.isInsufficientMaterial then else if status.isInsufficientMaterial then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
cancelScheduled() cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial)) notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
invoker.clear() redoStack = Nil
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext)) else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(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)) 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 = private def performUndo(): Unit =
if invoker.canUndo then if currentContext.moves.isEmpty then
val cmd = invoker.history(invoker.getCurrentIndex) notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
(cmd: @unchecked) match else
case moveCmd: MoveCommand => val lastMove = currentContext.moves.last
moveCmd.previousContext.foreach(currentContext = _) val prevCtx = replayContextFromMoves(currentContext.moves.dropRight(1))
invoker.undo() val notation = translateMoveToNotation(lastMove, prevCtx.board)
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation)) redoStack = lastMove :: redoStack
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo)) currentContext = prevCtx
notifyObservers(MoveUndoneEvent(currentContext, notation))
private def performRedo(): Unit = private def performRedo(): Unit =
if invoker.canRedo then if redoStack.isEmpty then
val cmd = invoker.history(invoker.getCurrentIndex + 1) notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
(cmd: @unchecked) match else
case moveCmd: MoveCommand => val move = redoStack.head
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do redoStack = redoStack.tail
currentContext = nextCtx isRedoing = true
invoker.redo() executeMove(move)
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}") isRedoing = false
notifyObservers(
MoveRedoneEvent( def requestTakeback(color: Color): Unit = synchronized {
currentContext, if currentContext.result.isDefined then
moveCmd.notation, notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
moveCmd.from.toString, else if currentContext.moves.isEmpty then
moveCmd.to.toString, notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
capturedDesc, else
), pendingTakebackRequest match
) case Some(_) =>
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo)) 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))
}
@@ -19,3 +19,8 @@ enum InvalidMoveReason:
case CannotAcceptOwnDrawOffer case CannotAcceptOwnDrawOffer
case NoDrawOfferToDecline case NoDrawOfferToDecline
case CannotDeclineOwnDrawOffer case CannotDeclineOwnDrawOffer
case TakebackRequestPending
case NoTakebackRequestToAccept
case CannotAcceptOwnTakebackRequest
case NoTakebackRequestToDecline
case CannotDeclineOwnTakebackRequest
@@ -60,15 +60,6 @@ case class MoveUndoneEvent(
pgnNotation: String, pgnNotation: String,
) extends GameEvent ) 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. */ /** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent( case class PgnLoadedEvent(
context: GameContext, context: GameContext,
@@ -98,6 +89,18 @@ case class TimeFlagEvent(
flaggedColor: Color, flaggedColor: Color,
) extends GameEvent ) 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. */ /** Observer trait: implement to receive game state updates. */
trait Observer: trait Observer:
def onGameEvent(event: GameEvent): Unit def onGameEvent(event: GameEvent): Unit
@@ -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
@@ -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)
}
@@ -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))
)
@@ -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,
)
@@ -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,
)
@@ -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
@@ -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
@@ -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))
@@ -22,6 +22,7 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException} import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter} import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.redis.GameRedisSubscriberManager
import de.nowchess.chess.registry.{GameEntry, GameRegistry} import de.nowchess.chess.registry.{GameEntry, GameRegistry}
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -51,6 +52,9 @@ class GameResource:
@Inject @Inject
var jwt: JsonWebToken = uninitialized var jwt: JsonWebToken = uninitialized
@Inject
var subscriberManager: GameRedisSubscriberManager = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1") private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
@@ -79,31 +83,6 @@ class GameResource:
// ── mapping ────────────────────────────────────────────────────────────── // ── 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 = private def toLegalMoveDto(move: Move): LegalMoveDto =
val (moveTypeStr, promotionStr) = move.moveType match val (moveTypeStr, promotionStr) = move.moveType match
case MoveType.Normal(false) => ("normal", None) case MoveType.Normal(false) => ("normal", None)
@@ -115,41 +94,7 @@ class GameResource:
case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook")) case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop")) case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight")) case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveTypeStr, promotionStr) LegalMoveDto(move.from.toString, move.to.toString, GameDtoMapper.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))
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo = private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName)) dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
@@ -213,15 +158,16 @@ class GameResource:
val mode = req.mode.getOrElse(GameMode.Open) val mode = req.mode.getOrElse(GameMode.Open)
val entry = newEntry(GameContext.initial, white, black, tc, mode) val entry = newEntry(GameContext.initial, white, black, tc, mode)
registry.store(entry) registry.store(entry)
subscriberManager.subscribeGame(entry.gameId)
println(s"Created game ${entry.gameId}") println(s"Created game ${entry.gameId}")
created(toGameFullDto(entry)) created(GameDtoMapper.toGameFullDto(entry, ioClient))
@GET @GET
@Path("/{gameId}") @Path("/{gameId}")
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
def getGame(@PathParam("gameId") gameId: String): Response = def getGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId)) val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
ok(toGameFullDto(entry)) ok(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST @POST
@Path("/{gameId}/resign") @Path("/{gameId}/resign")
@@ -244,7 +190,8 @@ class GameResource:
if Parser.parseMove(uci).isEmpty then if Parser.parseMove(uci).isEmpty then
throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")) throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, 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 @GET
@Path("/{gameId}/moves") @Path("/{gameId}/moves")
@@ -271,7 +218,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId)) val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo") if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
entry.engine.undo() entry.engine.undo()
ok(toGameStateDto(entry)) registry.update(entry)
ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@POST @POST
@Path("/{gameId}/redo") @Path("/{gameId}/redo")
@@ -280,7 +228,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId)) val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo") if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
entry.engine.redo() entry.engine.redo()
ok(toGameStateDto(entry)) registry.update(entry)
ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@POST @POST
@Path("/{gameId}/draw/{action}") @Path("/{gameId}/draw/{action}")
@@ -293,12 +242,28 @@ class GameResource:
assertGameNotOver(entry) assertGameNotOver(entry)
val color = colorOf(entry) val color = colorOf(entry)
action match action match
case "offer" => entry.engine.offerDraw(color); ok(OkResponseDto()) case "offer" => entry.engine.offerDraw(color); registry.update(entry); ok(OkResponseDto())
case "accept" => entry.engine.acceptDraw(color); ok(OkResponseDto()) case "accept" => entry.engine.acceptDraw(color); registry.update(entry); ok(OkResponseDto())
case "decline" => entry.engine.declineDraw(color); ok(OkResponseDto()) case "decline" => entry.engine.declineDraw(color); registry.update(entry); ok(OkResponseDto())
case "claim" => entry.engine.claimDraw(); ok(OkResponseDto()) case "claim" => entry.engine.claimDraw(); registry.update(entry); ok(OkResponseDto())
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action")) 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 @POST
@Path("/import/fen") @Path("/import/fen")
@Consumes(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON))
@@ -310,7 +275,8 @@ class GameResource:
val tc = toTimeControl(body.timeControl) val tc = toTimeControl(body.timeControl)
val entry = newEntry(ctx, white, black, tc) val entry = newEntry(ctx, white, black, tc)
registry.store(entry) registry.store(entry)
created(toGameFullDto(entry)) subscriberManager.subscribeGame(entry.gameId)
created(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST @POST
@Path("/import/pgn") @Path("/import/pgn")
@@ -320,7 +286,8 @@ class GameResource:
val ctx = ioClient.importPgn(body.pgn) val ctx = ioClient.importPgn(body.pgn)
val entry = newEntry(ctx, DefaultWhite, DefaultBlack) val entry = newEntry(ctx, DefaultWhite, DefaultBlack)
registry.store(entry) registry.store(entry)
created(toGameFullDto(entry)) subscriberManager.subscribeGame(entry.gameId)
created(GameDtoMapper.toGameFullDto(entry, ioClient))
@GET @GET
@Path("/{gameId}/export/fen") @Path("/{gameId}/export/fen")
@@ -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))
@@ -7,3 +7,9 @@ quarkus:
io-grpc: io-grpc:
host: localhost host: localhost
port: 9081 port: 9081
nowchess:
redis:
host: localhost
port: 6379
prefix: test-core
+12
View File
@@ -115,3 +115,15 @@ tasks.reportScoverage {
tasks.jar { tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE 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"))
}
+12
View File
@@ -115,3 +115,15 @@ tasks.reportScoverage {
tasks.jar { tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE 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"))
}
+108
View File
@@ -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<String, String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
tasks.withType<ScalaCompile> {
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<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().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
}
@@ -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
@@ -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()
@@ -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
@@ -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],
)
@@ -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)
)
@@ -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)
@@ -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())
@@ -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 _ => ()
+92
View File
@@ -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<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedFiles.set(scoverageExcluded)
}
tasks.withType<ScalaCompile> {
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<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().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 }
@@ -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}
@@ -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
@@ -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())
@@ -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)
}
@@ -0,0 +1,11 @@
quarkus:
http:
port: 8084
application:
name: nowchess-ws
nowchess:
redis:
host: localhost
port: 6379
prefix: test
+2
View File
@@ -21,4 +21,6 @@ include(
"modules:rule", "modules:rule",
"modules:bot", "modules:bot",
"modules:account", "modules:account",
"modules:ws",
"modules:store",
) )