feat(coordinator): add Redis integration and improve configuration for game state management
Build & Test (NowChessSystems) TeamCity build was removed from queue

This commit is contained in:
2026-04-26 18:25:03 +02:00
parent f327441089
commit 106b4d3b7e
56 changed files with 1072 additions and 1139 deletions
@@ -1,6 +1,7 @@
syntax = "proto3";
package de.nowchess.coordinator;
option java_package = "de.nowchess.coordinator.proto";
option java_multiple_files = true;
option java_outer_classname = "CoordinatorServiceProto";
service CoordinatorService {
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
@@ -1,24 +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,
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,
)
@@ -391,7 +391,7 @@ class GameEngine(
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
val notation = translateMoveToNotation(move, contextBefore.board)
currentContext = nextContext
currentContext = nextContext
advanceClock(contextBefore.turn)
@@ -533,8 +533,7 @@ class GameEngine(
notifyObservers(MoveUndoneEvent(currentContext, notation))
private def performRedo(): Unit =
if redoStack.isEmpty then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
if redoStack.isEmpty then notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
else
val move = redoStack.head
redoStack = redoStack.tail
@@ -1,66 +1,63 @@
package de.nowchess.chess.grpc
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import de.nowchess.coordinator.CoordinatorServiceGrpc
import de.nowchess.coordinator.{
BatchResubscribeRequest,
BatchResubscribeResponse,
UnsubscribeGamesRequest,
UnsubscribeGamesResponse,
EvictGamesRequest,
EvictGamesResponse,
DrainInstanceRequest,
DrainInstanceResponse
}
import jakarta.inject.Singleton
import io.quarkus.grpc.GrpcService
import scala.compiletime.uninitialized
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
import de.nowchess.chess.redis.GameRedisSubscriberManager
import io.grpc.stub.StreamObserver
import scala.jdk.CollectionConverters.*
@ApplicationScoped
@GrpcService
@Singleton
class CoordinatorServiceHandler extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
@Inject
private var gameSubscriberManager: GameRedisSubscriberManager = _
private var gameSubscriberManager: GameRedisSubscriberManager = uninitialized
override def batchResubscribeGames(
request: BatchResubscribeRequest,
responseObserver: StreamObserver[BatchResubscribeResponse]
request: BatchResubscribeRequest,
responseObserver: StreamObserver[BatchResubscribeResponse],
): Unit =
val count = gameSubscriberManager.batchResubscribeGames(request.getGameIdsList)
val response = BatchResubscribeResponse.newBuilder()
val response = BatchResubscribeResponse
.newBuilder()
.setSubscribedCount(count)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
override def unsubscribeGames(
request: UnsubscribeGamesRequest,
responseObserver: StreamObserver[UnsubscribeGamesResponse]
request: UnsubscribeGamesRequest,
responseObserver: StreamObserver[UnsubscribeGamesResponse],
): Unit =
val count = gameSubscriberManager.unsubscribeGames(request.getGameIdsList)
val response = UnsubscribeGamesResponse.newBuilder()
val response = UnsubscribeGamesResponse
.newBuilder()
.setUnsubscribedCount(count)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
override def evictGames(
request: EvictGamesRequest,
responseObserver: StreamObserver[EvictGamesResponse]
request: EvictGamesRequest,
responseObserver: StreamObserver[EvictGamesResponse],
): Unit =
val count = gameSubscriberManager.evictGames(request.getGameIdsList)
val response = EvictGamesResponse.newBuilder()
val response = EvictGamesResponse
.newBuilder()
.setEvictedCount(count)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
override def drainInstance(
request: DrainInstanceRequest,
responseObserver: StreamObserver[DrainInstanceResponse]
request: DrainInstanceRequest,
responseObserver: StreamObserver[DrainInstanceResponse],
): Unit =
gameSubscriberManager.drainInstance()
val response = DrainInstanceResponse.newBuilder()
val response = DrainInstanceResponse
.newBuilder()
.setGamesMigrated(0)
.build()
responseObserver.onNext(response)
@@ -1,7 +1,7 @@
package de.nowchess.chess.grpc
import de.nowchess.api.board.*
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights}
import de.nowchess.api.board.CastlingRights as DomainCastlingRights
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
import de.nowchess.core.proto.*
@@ -58,7 +58,12 @@ object CoreProtoMapper:
case _ => MoveType.Normal(false)
def toProtoMove(m: DomainMove): ProtoMove =
ProtoMove.newBuilder().setFrom(m.from.toString).setTo(m.to.toString).setMoveKind(toProtoMoveKind(m.moveType)).build()
ProtoMove
.newBuilder()
.setFrom(m.from.toString)
.setTo(m.to.toString)
.setMoveKind(toProtoMoveKind(m.moveType))
.build()
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
for
@@ -67,16 +72,33 @@ object CoreProtoMapper:
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
board.pieces.map { (sq, piece) =>
ProtoSquarePiece
.newBuilder()
.setSquare(sq.toString)
.setPiece(ProtoPiece.newBuilder().setColor(toProtoColor(piece.color)).setPieceType(toProtoPieceType(piece.pieceType)).build())
.build()
}.toSeq.asJava
board.pieces
.map { (sq, piece) =>
ProtoSquarePiece
.newBuilder()
.setSquare(sq.toString)
.setPiece(
ProtoPiece
.newBuilder()
.setColor(toProtoColor(piece.color))
.setPieceType(toProtoPieceType(piece.pieceType))
.build(),
)
.build()
}
.toSeq
.asJava
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
Board(pieces.asScala.flatMap(sp => Square.fromAlgebraic(sp.getSquare).map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))).toMap)
Board(
pieces.asScala
.flatMap(sp =>
Square
.fromAlgebraic(sp.getSquare)
.map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType))),
)
.toMap,
)
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
case None => ProtoGameResultKind.ONGOING
@@ -131,12 +153,13 @@ object CoreProtoMapper:
def fromProtoGameContext(p: ProtoGameContext): GameContext =
val cr = p.getCastlingRights
GameContext(
board = fromProtoBoard(p.getBoardList),
turn = fromProtoColor(p.getTurn),
castlingRights = DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
board = fromProtoBoard(p.getBoardList),
turn = fromProtoColor(p.getTurn),
castlingRights =
DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
halfMoveClock = p.getHalfMoveClock,
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
result = fromProtoResultKind(p.getResult),
initialBoard = fromProtoBoard(p.getInitialBoardList),
halfMoveClock = p.getHalfMoveClock,
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
result = fromProtoResultKind(p.getResult),
initialBoard = fromProtoBoard(p.getInitialBoardList),
)
@@ -20,15 +20,22 @@ class RuleSetGrpcAdapter extends RuleSet:
// scalafix:on DisableSyntax.var
def candidateMoves(ctx: GameContext)(sq: Square): List[Move] =
val req = ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
val req =
ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
stub.candidateMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
def legalMoves(ctx: GameContext)(sq: Square): List[Move] =
val req = ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
val req =
ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
stub.legalMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
def allLegalMoves(ctx: GameContext): List[Move] =
stub.allLegalMoves(CoreProtoMapper.toProtoGameContext(ctx)).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
stub
.allLegalMoves(CoreProtoMapper.toProtoGameContext(ctx))
.getMovesList
.asScala
.flatMap(CoreProtoMapper.fromProtoMove)
.toList
def isCheck(ctx: GameContext): Boolean =
stub.isCheck(CoreProtoMapper.toProtoGameContext(ctx)).getValue
@@ -49,9 +56,19 @@ class RuleSetGrpcAdapter extends RuleSet:
stub.isThreefoldRepetition(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def applyMove(ctx: GameContext)(move: Move): GameContext =
val req = ProtoMoveRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setMove(CoreProtoMapper.toProtoMove(move)).build()
val req = ProtoMoveRequest
.newBuilder()
.setContext(CoreProtoMapper.toProtoGameContext(ctx))
.setMove(CoreProtoMapper.toProtoMove(move))
.build()
CoreProtoMapper.fromProtoGameContext(stub.applyMove(req))
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
val p = stub.postMoveStatus(CoreProtoMapper.toProtoGameContext(ctx))
PostMoveStatus(p.getIsCheckmate, p.getIsStalemate, p.getIsInsufficientMaterial, p.getIsCheck, p.getIsThreefoldRepetition)
PostMoveStatus(
p.getIsCheckmate,
p.getIsStalemate,
p.getIsInsufficientMaterial,
p.getIsCheck,
p.getIsThreefoldRepetition,
)
@@ -3,6 +3,6 @@ package de.nowchess.chess.redis
sealed trait C2sMessage
object C2sMessage:
case object Connected extends C2sMessage
case object Connected extends C2sMessage
case class Move(uci: String) extends C2sMessage
case object Ping extends C2sMessage
case object Ping extends C2sMessage
@@ -22,7 +22,7 @@ class GameRedisPublisher(
def onGameEvent(event: GameEvent): Unit =
registry.get(gameId).foreach { entry =>
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
redisson.getTopic(s2cTopicName).publish(json)
@@ -38,9 +38,15 @@ class GameRedisPublisher(
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 },
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 },
@@ -21,12 +21,12 @@ import java.util.concurrent.ConcurrentHashMap
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
@Inject(optional = true) var heartbeatService: InstanceHeartbeatService = uninitialized
@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
@Inject var heartbeatService: InstanceHeartbeatService = null
// scalafix:on DisableSyntax.var
private val c2sListeners = new ConcurrentHashMap[String, Int]()
@@ -41,20 +41,30 @@ class GameRedisSubscriberManager:
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)
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 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)
val obs = new GameRedisPublisher(
gameId,
registry,
redisson,
objectMapper,
s2cTopicName(gameId),
writebackFn,
ioClient,
unsubscribeGame,
)
s2cObservers.put(gameId, obs)
registry.get(gameId).foreach(_.engine.subscribe(obs))
if heartbeatService != null then
heartbeatService.addGameSubscription(gameId)
if heartbeatService != null then heartbeatService.addGameSubscription(gameId)
catch
case e: Exception =>
System.err.println(s"Warning: Redis subscription failed for game $gameId: ${e.getMessage}")
@@ -68,19 +78,18 @@ class GameRedisSubscriberManager:
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
}
if heartbeatService != null then
heartbeatService.removeGameSubscription(gameId)
if heartbeatService != null then heartbeatService.removeGameSubscription(gameId)
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 => ()
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 dto = GameDtoMapper.toGameFullDto(entry, ioClient)
val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
redisson.getTopic(s2cTopicName(gameId)).publish(json)
}
@@ -120,7 +129,6 @@ class GameRedisSubscriberManager:
var count = 0
gameIds.forEach { gameId =>
unsubscribeGame(gameId)
registry.remove(gameId)
count += 1
}
count
@@ -131,9 +139,5 @@ class GameRedisSubscriberManager:
@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))
)
c2sListeners.forEach((gameId, listenerId) => redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId))
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
@@ -1,26 +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,
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,
)
@@ -1,25 +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,
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,
)
@@ -16,6 +16,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.redisson.api.RedissonClient
import scala.annotation.nowarn
import scala.compiletime.uninitialized
import scala.util.Try
import java.nio.charset.StandardCharsets
@@ -27,19 +28,19 @@ import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
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
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 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))
private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
def generateId(): String =
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@@ -48,7 +49,9 @@ class RedisGameRegistry extends GameRegistry:
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)
val b = bucket(entry.gameId)
b.set(toJson(entry, combined.fen, combined.pgn))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
def get(gameId: String): Option[GameEntry] =
Option(localEngines.get(gameId)) match
@@ -63,7 +66,9 @@ class RedisGameRegistry extends GameRegistry:
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)
val b = bucket(entry.gameId)
b.set(toJson(entry, combined.fen, combined.pgn))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
private def readRedisDto(gameId: String): Option[GameCacheDto] =
Try(Option(bucket(gameId).get())).toOption.flatten.flatMap { json =>
@@ -106,7 +111,9 @@ class RedisGameRegistry extends GameRegistry:
}.toOption
.map { case (dto, entry) =>
localEngines.put(gameId, entry)
bucket(gameId).set(objectMapper.writeValueAsString(dto), 30, TimeUnit.MINUTES)
val b = bucket(gameId)
b.set(objectMapper.writeValueAsString(dto))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
entry
}
@@ -118,28 +125,31 @@ class RedisGameRegistry extends GameRegistry:
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,
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),
)
}
}
val restoredDrawOffer = dto.pendingDrawOffer.map(toColor)
.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)
ruleSetAdapter
.legalMoves(ctx)(from)
.find(m => m.to == to && (pp.isEmpty || m.moveType == de.nowchess.api.move.MoveType.Promotion(pp.get)))
}
}
@@ -195,8 +205,8 @@ class RedisGameRegistry extends GameRegistry:
private def entryHash(entry: GameEntry): Option[String] =
Try {
val combined = ioClient.exportCombined(entry.engine.context)
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))
val digest = MessageDigest.getInstance("SHA-256").digest(canonicalJson.getBytes(StandardCharsets.UTF_8))
digest.map("%02x".format(_)).mkString
}.toOption
@@ -17,9 +17,9 @@ object GameDtoMapper:
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.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"
@@ -54,7 +54,7 @@ object GameDtoMapper:
}
def toGameStateDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameStateDto =
val ctx = entry.engine.context
val ctx = entry.engine.context
val exported = ioClient.exportCombined(ctx)
GameStateDto(
fen = exported.fen,
@@ -260,7 +260,8 @@ class GameResource:
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 "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"))
@@ -7,34 +7,39 @@ import io.quarkus.runtime.StartupEvent
import io.quarkus.runtime.ShutdownEvent
import io.quarkus.grpc.GrpcClient
import org.redisson.api.RedissonClient
import scala.annotation.nowarn
import scala.concurrent.duration.*
import scala.compiletime.uninitialized
import java.util.concurrent.{Executors, TimeUnit}
import java.net.InetAddress
import com.fasterxml.jackson.databind.ObjectMapper
import org.jboss.logging.Logger
import de.nowchess.coordinator.{HeartbeatFrame, CoordinatorServiceGrpc}
import de.nowchess.coordinator.CoordinatorServiceGrpc.CoordinatorServiceStub
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
import de.nowchess.coordinator.proto.CoordinatorServiceGrpc.CoordinatorServiceStub
import io.grpc.stub.StreamObserver
import io.grpc.Channel
import scala.jdk.FutureConverters.*
@ApplicationScoped
class InstanceHeartbeatService:
@Inject
private var redissonClient: RedissonClient = _
private var redissonClient: RedissonClient = uninitialized
@GrpcClient("coordinator-grpc")
private var coordinatorStub: CoordinatorServiceStub = _
private var channel: Channel = uninitialized
private val log = Logger.getLogger(classOf[InstanceHeartbeatService])
private var coordinatorStub: CoordinatorServiceStub = uninitialized
private val log = Logger.getLogger(classOf[InstanceHeartbeatService])
private val mapper = ObjectMapper()
private var instanceId = ""
private var redisPrefix = "nowchess"
private var instanceId = ""
private var redisPrefix = "nowchess"
private var streamObserver: Option[StreamObserver[HeartbeatFrame]] = None
private var heartbeatExecutor = Executors.newScheduledThreadPool(1)
private var redisHeartbeatExecutor = Executors.newScheduledThreadPool(1)
private var subscriptionCount = 0
private var localCacheSize = 0
private var heartbeatExecutor = Executors.newScheduledThreadPool(1)
private var redisHeartbeatExecutor = Executors.newScheduledThreadPool(1)
private var subscriptionCount = 0
private var localCacheSize = 0
def onStart(@Observes event: StartupEvent): Unit =
try
@@ -64,35 +69,34 @@ class InstanceHeartbeatService:
localCacheSize = count
def addGameSubscription(gameId: String): Unit =
val setKey = s"$redisPrefix:instance:$instanceId:games"
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.add(gameId)
subscriptionCount += 1
def removeGameSubscription(gameId: String): Unit =
val setKey = s"$redisPrefix:instance:$instanceId:games"
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.remove(gameId)
subscriptionCount = Math.max(0, subscriptionCount - 1)
private def generateInstanceId(): Unit =
val hostname = try
InetAddress.getLocalHost.getHostName
catch
case _: Exception => "unknown"
val hostname =
try InetAddress.getLocalHost.getHostName
catch case _: Exception => "unknown"
val uuid = java.util.UUID.randomUUID().toString.take(8)
instanceId = s"$hostname-$uuid"
private def initializeHeartbeatStream(): Unit =
try
val responseObserver = new StreamObserver[de.nowchess.coordinator.CoordinatorCommand]:
override def onNext(value: de.nowchess.coordinator.CoordinatorCommand): Unit =
coordinatorStub = CoordinatorServiceGrpc.newStub(channel)
val responseObserver = new StreamObserver[CoordinatorCommand]:
override def onNext(value: CoordinatorCommand): Unit =
log.debugf("Received coordinator command: %s", value.getType)
override def onError(t: Throwable): Unit =
log.warnf(t, "Heartbeat stream error")
// Reconnect on error
() // Placeholder for reconnect logic
override def onCompleted: Unit =
@@ -111,7 +115,7 @@ class InstanceHeartbeatService:
() => sendHeartbeat(),
0,
200,
TimeUnit.MILLISECONDS
TimeUnit.MILLISECONDS,
)
// Refresh Redis TTL every 2s
@@ -119,13 +123,14 @@ class InstanceHeartbeatService:
() => refreshRedisHeartbeat(),
0,
2,
TimeUnit.SECONDS
TimeUnit.SECONDS,
)
private def sendHeartbeat(): Unit =
streamObserver.foreach { observer =>
try
val frame = HeartbeatFrame.newBuilder()
val frame = HeartbeatFrame
.newBuilder()
.setInstanceId(instanceId)
.setHostname(getHostname)
.setHttpPort(8080) // Placeholder, should be configurable
@@ -142,31 +147,30 @@ class InstanceHeartbeatService:
private def refreshRedisHeartbeat(): Unit =
try
val key = s"$redisPrefix:instances:$instanceId"
val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key)
val metadata = Map(
"instanceId" -> instanceId,
"hostname" -> getHostname,
"httpPort" -> 8080,
"grpcPort" -> 9080,
"instanceId" -> instanceId,
"hostname" -> getHostname,
"httpPort" -> 8080,
"grpcPort" -> 9080,
"subscriptionCount" -> subscriptionCount,
"localCacheSize" -> localCacheSize,
"lastHeartbeat" -> java.time.Instant.now().toString,
"state" -> "HEALTHY"
"localCacheSize" -> localCacheSize,
"lastHeartbeat" -> java.time.Instant.now().toString,
"state" -> "HEALTHY",
)
val json = mapper.writeValueAsString(metadata)
bucket.set(json, 5, TimeUnit.SECONDS) // 5-second TTL, refreshed every 2s
bucket.set(json)
(bucket.expire(5, TimeUnit.SECONDS): @nowarn)
catch
case ex: Exception =>
log.warnf(ex, "Failed to refresh Redis heartbeat")
private def getHostname: String =
try
InetAddress.getLocalHost.getHostName
catch
case _: Exception => "unknown"
try InetAddress.getLocalHost.getHostName
catch case _: Exception => "unknown"
private def cleanup(): Unit =
streamObserver.foreach(_.onCompleted())
@@ -180,7 +184,5 @@ class InstanceHeartbeatService:
heartbeatExecutor.shutdown()
redisHeartbeatExecutor.shutdown()
if !heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then
heartbeatExecutor.shutdownNow()
if !redisHeartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then
redisHeartbeatExecutor.shutdownNow()
if !heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then heartbeatExecutor.shutdownNow()
if !redisHeartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then redisHeartbeatExecutor.shutdownNow()
@@ -7,6 +7,9 @@ quarkus:
io-grpc:
host: localhost
port: 9081
rest-client:
store-service:
url: http://localhost:8085
nowchess:
redis:
@@ -1,153 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private case class FailingCommand() extends Command:
override def execute(): Boolean = false
override def undo(): Boolean = false
override def description: String = "Failing command"
private class ConditionalFailCommand(
initialShouldFailOnUndo: Boolean = false,
initialShouldFailOnExecute: Boolean = false,
) extends Command:
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
override def execute(): Boolean = !shouldFailOnExecute.get()
override def undo(): Boolean = !shouldFailOnUndo.get()
override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
previousContext = Some(GameContext.initial),
)
test("execute rejects failing commands and keeps history unchanged"):
val invoker = new CommandInvoker()
val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
val failingCmd = FailingCommand()
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(failingCmd) shouldBe false
invoker.history.size shouldBe 0
invoker.execute(successCmd) shouldBe true
invoker.history.size shouldBe 1
invoker.history.head shouldBe successCmd
test("undo redo and history trimming cover all command state transitions"):
{
val invoker = new CommandInvoker()
invoker.undo() shouldBe false
invoker.canUndo shouldBe false
invoker.undo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.undo()
invoker.undo() shouldBe false
}
{
val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true
invoker.undo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val successUndoCmd = ConditionalFailCommand()
invoker.execute(successUndoCmd) shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
}
{
val invoker = new CommandInvoker()
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand()
invoker.execute(cmd1)
invoker.execute(redoFailCmd)
invoker.undo()
invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute.set(true)
invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.undo() shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.execute(cmd3)
invoker.execute(cmd4)
invoker.undo()
invoker.undo()
invoker.canRedo shouldBe true
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
invoker.execute(newCmd)
invoker.history.size shouldBe 3
invoker.canRedo shouldBe false
}
@@ -1,67 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def createMoveCommand(from: Square, to: Square): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial),
)
test("execute appends commands and updates index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd2) shouldBe true
invoker.history.size shouldBe 2
invoker.getCurrentIndex shouldBe 1
test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false
invoker.execute(cmd)
invoker.canUndo shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
invoker.canRedo shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
test("clear removes full history and resets index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.clear()
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
test("execute after undo discards redo history"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.getCurrentIndex shouldBe 0
invoker.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history.head shouldBe cmd1
invoker.history(1) shouldBe cmd3
invoker.getCurrentIndex shouldBe 1
@@ -1,23 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand properties and behavior"):
val cmd = QuitCommand()
cmd.execute() shouldBe true
cmd.undo() shouldBe false
cmd.description shouldBe "Quit game"
test("ResetCommand behavior depends on previousContext"):
val noState = ResetCommand()
noState.execute() shouldBe true
noState.undo() shouldBe false
noState.description shouldBe "Reset board"
val withState = ResetCommand(previousContext = Some(GameContext.initial))
withState.execute() shouldBe true
withState.undo() shouldBe true
@@ -1,70 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("MoveCommand defaults to empty optional state and false execute/undo"):
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
cmd.moveResult shouldBe None
cmd.previousContext shouldBe None
cmd.execute() shouldBe false
cmd.undo() shouldBe false
cmd.description shouldBe "Move from e2 to e4"
test("MoveCommand execute/undo succeed when state is present"):
val executable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
)
executable.execute() shouldBe true
val undoable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial),
)
undoable.undo() shouldBe true
test("MoveCommand is immutable and preserves equality/hash semantics"):
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
val result = MoveResult.Successful(GameContext.initial, None)
val cmd2 = cmd1.copy(
moveResult = Some(result),
previousContext = Some(GameContext.initial),
)
cmd1.moveResult shouldBe None
cmd1.previousContext shouldBe None
cmd2.moveResult shouldBe Some(result)
cmd2.previousContext shouldBe Some(GameContext.initial)
val eq1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None,
)
val eq2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None,
)
eq1 shouldBe eq2
eq1.hashCode shouldBe eq2.hashCode
val hash1 = eq1.hashCode
val hash2 = eq1.hashCode
hash1 shouldBe hash2
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer}
import de.nowchess.api.error.GameError
import de.nowchess.api.io.GameContextImport
import de.nowchess.api.rules.RuleSet
@@ -21,15 +21,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.subscribe((event: GameEvent) => events += event)
events
test("accessors expose redo availability and command history"):
val engine = new GameEngine(ruleSet = DefaultRules)
engine.canRedo shouldBe false
engine.commandHistory shouldBe empty
engine.processUserInput("e2e4")
engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
@@ -72,26 +63,11 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.loadPosition(target)
engine.context shouldBe target
engine.commandHistory shouldBe empty
events.lastOption.exists {
case _: de.nowchess.chess.observer.BoardResetEvent => true
case _ => false
} shouldBe true
test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
events.clear()
engine.processUserInput("a1h1")
engine.processUserInput("undo")
engine.processUserInput("redo")
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
test("loadGame replay handles promotion moves when pending promotion exists"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
@@ -1,12 +1,22 @@
package de.nowchess.chess.registry
import de.nowchess.api.game.GameContext
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.client.{CombinedExportResponse, StoreServiceClient}
import de.nowchess.chess.grpc.IoGrpcClientWrapper
import de.nowchess.io.fen.FenExporter
import de.nowchess.io.pgn.PgnExporter
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.chess.engine.GameEngine
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.junit.jupiter.api.{DisplayName, Test}
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.when
import org.mockito.invocation.InvocationOnMock
import scala.compiletime.uninitialized
@@ -18,6 +28,25 @@ class GameRegistryImplTest:
@Inject
var registry: GameRegistry = uninitialized
@InjectMock
var ioWrapper: IoGrpcClientWrapper = uninitialized
@InjectMock
@RestClient
var storeClient: StoreServiceClient = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
de.nowchess.io.pgn.PgnParser
.importGameContext(inv.getArgument[String](0))
.getOrElse(GameContext.initial),
)
@Test
@DisplayName("store saves entry")
def testStore(): Unit =
@@ -7,7 +7,7 @@ import de.nowchess.chess.client.CombinedExportResponse
import de.nowchess.chess.exception.BadRequestException
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.io.fen.FenExporter
import de.nowchess.io.pgn.PgnParser
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
@@ -37,17 +37,19 @@ class GameResourceIntegrationTest:
@BeforeEach
def setupMocks(): Unit =
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenReturn(
PgnParser.importGameContext("1. e4 c5").toOption.get,
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
)
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), ""),
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
)
when(ioWrapper.exportFen(any())).thenAnswer((inv: InvocationOnMock) =>
FenExporter.exportGameContext(inv.getArgument[GameContext](0)),
)
when(ioWrapper.exportPgn(any())).thenReturn("")
when(ioWrapper.exportPgn(any())).thenAnswer((inv: InvocationOnMock) =>
PgnExporter.exportGameContext(inv.getArgument[GameContext](0)),
)
when(ruleAdapter.legalMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.legalMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),