feat(redis): implement Redis integration for game state management and websocket communication
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user