feat(redis): implement Redis integration for game state management and websocket communication
This commit is contained in:
Generated
+1
@@ -17,6 +17,7 @@
|
||||
<option value="$PROJECT_DIR$/modules/io" />
|
||||
<option value="$PROJECT_DIR$/modules/json" />
|
||||
<option value="$PROJECT_DIR$/modules/rule" />
|
||||
<option value="$PROJECT_DIR$/modules/ws" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
</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="uncheckedWarnings" value="true" />
|
||||
<parameters>
|
||||
|
||||
@@ -11,5 +11,5 @@ get {
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
gameId: Yg200tOF
|
||||
gameId: j0nPtcjl
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
vars {
|
||||
baseUrl: http://localhost:8080
|
||||
wsBaseUrl: ws://localhost:8080
|
||||
wsBaseUrl: ws://localhost:8084
|
||||
ioBaseUrl: http://localhost:8081
|
||||
}
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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")) &&
|
||||
|
||||
@@ -10,4 +10,5 @@ final case class GameStateDto(
|
||||
undoAvailable: Boolean,
|
||||
redoAvailable: Boolean,
|
||||
clock: Option[ClockDto],
|
||||
takebackRequestedBy: Option[String] = None,
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.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))
|
||||
}
|
||||
|
||||
@@ -19,3 +19,8 @@ enum InvalidMoveReason:
|
||||
case CannotAcceptOwnDrawOffer
|
||||
case NoDrawOfferToDecline
|
||||
case CannotDeclineOwnDrawOffer
|
||||
case TakebackRequestPending
|
||||
case NoTakebackRequestToAccept
|
||||
case CannotAcceptOwnTakebackRequest
|
||||
case NoTakebackRequestToDecline
|
||||
case CannotDeclineOwnTakebackRequest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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")
|
||||
|
||||
@@ -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:
|
||||
host: localhost
|
||||
port: 9081
|
||||
|
||||
nowchess:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
prefix: test-core
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
+30
@@ -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 _ => ()
|
||||
@@ -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
|
||||
@@ -21,4 +21,6 @@ include(
|
||||
"modules:rule",
|
||||
"modules:bot",
|
||||
"modules:account",
|
||||
"modules:ws",
|
||||
"modules:store",
|
||||
)
|
||||
Reference in New Issue
Block a user