feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
@@ -0,0 +1,87 @@
syntax = "proto3";
option java_package = "de.nowchess.core.proto";
option java_multiple_files = true;
option java_outer_classname = "ChessTypesProto";
enum ProtoColor {
WHITE = 0;
BLACK = 1;
}
enum ProtoPieceType {
PAWN = 0;
KNIGHT = 1;
BISHOP = 2;
ROOK = 3;
QUEEN = 4;
KING = 5;
}
enum ProtoMoveKind {
QUIET = 0;
CAPTURE = 1;
CASTLE_KINGSIDE = 2;
CASTLE_QUEENSIDE = 3;
EN_PASSANT = 4;
PROMO_QUEEN = 5;
PROMO_ROOK = 6;
PROMO_BISHOP = 7;
PROMO_KNIGHT = 8;
}
enum ProtoGameResultKind {
ONGOING = 0;
WIN_CHECKMATE_W = 1;
WIN_CHECKMATE_B = 2;
WIN_RESIGN_W = 3;
WIN_RESIGN_B = 4;
WIN_TIME_W = 5;
WIN_TIME_B = 6;
DRAW_STALEMATE = 7;
DRAW_INSUFFICIENT = 8;
DRAW_FIFTY_MOVE = 9;
DRAW_THREEFOLD = 10;
DRAW_AGREEMENT = 11;
}
message ProtoPiece {
ProtoColor color = 1;
ProtoPieceType piece_type = 2;
}
message ProtoSquarePiece {
string square = 1;
ProtoPiece piece = 2;
}
message ProtoMove {
string from = 1;
string to = 2;
ProtoMoveKind move_kind = 3;
}
message ProtoCastlingRights {
bool white_king_side = 1;
bool white_queen_side = 2;
bool black_king_side = 3;
bool black_queen_side = 4;
}
message ProtoGameContext {
repeated ProtoSquarePiece board = 1;
ProtoColor turn = 2;
ProtoCastlingRights castling_rights = 3;
string en_passant_square = 4;
int32 half_move_clock = 5;
repeated ProtoMove moves = 6;
ProtoGameResultKind result = 7;
repeated ProtoSquarePiece initial_board = 8;
}
message ProtoPostMoveStatus {
bool is_checkmate = 1;
bool is_stalemate = 2;
bool is_insufficient_material = 3;
bool is_check = 4;
bool is_threefold_repetition = 5;
}
@@ -0,0 +1,58 @@
syntax = "proto3";
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);
rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
}
message HeartbeatFrame {
string instanceId = 1;
string hostname = 2;
int32 httpPort = 3;
int32 grpcPort = 4;
int32 subscriptionCount = 5;
int32 localCacheSize = 6;
int64 timestampMillis = 7;
}
message CoordinatorCommand {
string type = 1;
string payload = 2;
}
message BatchResubscribeRequest {
repeated string gameIds = 1;
}
message BatchResubscribeResponse {
int32 subscribedCount = 1;
repeated string failedGameIds = 2;
}
message UnsubscribeGamesRequest {
repeated string gameIds = 1;
}
message UnsubscribeGamesResponse {
int32 unsubscribedCount = 1;
}
message EvictGamesRequest {
repeated string gameIds = 1;
}
message EvictGamesResponse {
int32 evictedCount = 1;
}
message DrainInstanceRequest {}
message DrainInstanceResponse {
int32 gamesMigrated = 1;
}
@@ -0,0 +1,31 @@
syntax = "proto3";
option java_package = "de.nowchess.core.proto";
option java_multiple_files = true;
option java_outer_classname = "IoServiceProto";
import "chess_types.proto";
message ProtoImportFenRequest {
string fen = 1;
}
message ProtoImportPgnRequest {
string pgn = 1;
}
message ProtoCombinedExport {
string fen = 1;
string pgn = 2;
}
message ProtoStringResult {
string value = 1;
}
service IoService {
rpc ImportFen (ProtoImportFenRequest) returns (ProtoGameContext);
rpc ImportPgn (ProtoImportPgnRequest) returns (ProtoGameContext);
rpc ExportCombined (ProtoGameContext) returns (ProtoCombinedExport);
rpc ExportFen (ProtoGameContext) returns (ProtoStringResult);
rpc ExportPgn (ProtoGameContext) returns (ProtoStringResult);
}
@@ -0,0 +1,38 @@
syntax = "proto3";
option java_package = "de.nowchess.core.proto";
option java_multiple_files = true;
option java_outer_classname = "RuleServiceProto";
import "chess_types.proto";
message ProtoSquareRequest {
ProtoGameContext context = 1;
string square = 2;
}
message ProtoMoveRequest {
ProtoGameContext context = 1;
ProtoMove move = 2;
}
message ProtoMoveList {
repeated ProtoMove moves = 1;
}
message ProtoBoolResult {
bool value = 1;
}
service RuleService {
rpc CandidateMoves (ProtoSquareRequest) returns (ProtoMoveList);
rpc LegalMoves (ProtoSquareRequest) returns (ProtoMoveList);
rpc AllLegalMoves (ProtoGameContext) returns (ProtoMoveList);
rpc IsCheck (ProtoGameContext) returns (ProtoBoolResult);
rpc IsCheckmate (ProtoGameContext) returns (ProtoBoolResult);
rpc IsStalemate (ProtoGameContext) returns (ProtoBoolResult);
rpc IsInsufficientMaterial (ProtoGameContext) returns (ProtoBoolResult);
rpc IsFiftyMoveRule (ProtoGameContext) returns (ProtoBoolResult);
rpc IsThreefoldRepetition (ProtoGameContext) returns (ProtoBoolResult);
rpc ApplyMove (ProtoMoveRequest) returns (ProtoGameContext);
rpc PostMoveStatus (ProtoGameContext) returns (ProtoPostMoveStatus);
}
+108 -5
View File
@@ -3,8 +3,111 @@ quarkus:
port: 8080
application:
name: nowchess-core
rest-client:
io-service:
url: http://localhost:8081
rule-service:
url: http://localhost:8082
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
grpc:
clients:
rule-grpc:
host: localhost
port: 8082
io-grpc:
host: localhost
port: 8081
coordinator-grpc:
host: localhost
port: 9086
server:
use-separate-server: false
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
internal:
secret: 123abc
coordinator:
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
host: localhost
grpc-port: 9086
stream-heartbeat-interval: 200ms
redis-heartbeat-interval: 2s
instance-id: ${HOSTNAME:local}-${quarkus.uuid}
"%dev":
mp:
jwt:
verify:
publickey:
location: keys/public.pem
issuer: nowchess
quarkus:
http:
cors:
~: true
origins: http://localhost:4200
methods: GET,POST,PUT,DELETE,OPTIONS
headers: Content-Type,Accept,Authorization
grpc:
clients:
rule-grpc:
host: localhost
port: 8082
io-grpc:
host: localhost
port: 8081
rest-client:
io-service:
url: http://localhost:8081
rule-service:
url: http://localhost:8082
store-service:
url: http://localhost:8085
"%deployed":
mp:
jwt:
verify:
publickey:
location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
issuer: nowchess
quarkus:
http:
cors:
~: true
origins: ${CORS_ORIGINS}
methods: GET,POST,PUT,DELETE,OPTIONS
headers: Content-Type,Accept,Authorization
grpc:
clients:
rule-grpc:
host: ${RULE_SERVICE_HOST}
port: ${RULE_SERVICE_GRPC_PORT:9082}
io-grpc:
host: ${IO_SERVICE_HOST}
port: ${IO_SERVICE_GRPC_PORT:9081}
coordinator-grpc:
host: ${COORDINATOR_SERVICE_HOST:localhost}
port: ${COORDINATOR_SERVICE_GRPC_PORT:9086}
rest-client:
io-service:
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}
coordinator:
enabled: ${NOWCHESS_COORDINATOR_ENABLED:true}
host: ${COORDINATOR_SERVICE_HOST:localhost}
grpc-port: ${COORDINATOR_SERVICE_GRPC_PORT:9086}
stream-heartbeat-interval: 200ms
redis-heartbeat-interval: 2s
instance-id: ${HOSTNAME:local}-${quarkus.uuid}
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
WQIDAQAB
-----END PUBLIC KEY-----
@@ -4,7 +4,7 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
import de.nowchess.api.rules.RuleSet
import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
@@ -49,3 +49,6 @@ class RuleSetRestAdapter extends RuleSet:
def applyMove(ctx: GameContext)(move: Move): GameContext =
client.applyMove(RuleMoveRequest(ctx, move))
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
client.postMoveStatus(ctx)
@@ -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,
)
@@ -2,12 +2,17 @@ package de.nowchess.chess.client
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
import de.nowchess.api.game.GameContext
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CombinedExportResponse(fen: String, pgn: String)
@Path("/io")
@RegisterRestClient(configKey = "io-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait IoServiceClient:
@POST
@@ -33,3 +38,9 @@ trait IoServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array("application/x-chess-pgn"))
def exportPgn(ctx: GameContext): String
@POST
@Path("/export/combined")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def exportCombined(ctx: GameContext): CombinedExportResponse
@@ -2,8 +2,11 @@ package de.nowchess.chess.client
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class RuleSquareRequest(context: GameContext, square: String)
@@ -11,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
@Path("/api/rules")
@RegisterRestClient(configKey = "rule-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait RuleServiceClient:
@POST
@@ -72,3 +76,9 @@ trait RuleServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: RuleMoveRequest): GameContext
@POST
@Path("/post-move-status")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def postMoveStatus(ctx: GameContext): PostMoveStatus
@@ -0,0 +1,16 @@
package de.nowchess.chess.client
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@RegisterRestClient(configKey = "store-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@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
}
@@ -2,11 +2,8 @@ package de.nowchess.chess.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Square
import de.nowchess.api.move.MoveType
import de.nowchess.chess.json.{MoveTypeDeserializer, MoveTypeSerializer, SquareDeserializer, SquareSerializer}
import de.nowchess.json.ChessJacksonModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -19,11 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
val mod = new SimpleModule()
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
mod.addSerializer(classOf[Square], new SquareSerializer())
mod.addDeserializer(classOf[Square], new SquareDeserializer())
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
mapper.registerModule(mod)
mapper.registerModule(new ChessJacksonModule())
@@ -2,13 +2,14 @@ package de.nowchess.chess.config
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.dto.*
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[ApiErrorDto],
classOf[ClockDto],
classOf[CreateGameRequestDto],
classOf[ErrorEventDto],
classOf[GameFullDto],
@@ -21,6 +22,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[LegalMovesResponseDto],
classOf[OkResponseDto],
classOf[PlayerInfoDto],
classOf[TimeControlDto],
classOf[GameContext],
classOf[Color],
classOf[Piece],
@@ -34,6 +36,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[PromotionPiece],
classOf[GameResult],
classOf[DrawReason],
classOf[GameMode],
),
)
class NativeReflectionConfig
@@ -0,0 +1,12 @@
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.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -1,8 +0,0 @@
package de.nowchess.chess.config
import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
import de.nowchess.api.board.Square
class SquareKeyDeserializer extends KeyDeserializer:
override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
Square.fromAlgebraic(key).orNull
@@ -1,9 +0,0 @@
package de.nowchess.chess.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.board.Square
class SquareKeySerializer extends JsonSerializer[Square]:
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeFieldName(value.toString)
@@ -2,15 +2,25 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{BotParticipant, DrawReason, GameContext, GameResult, Human, Participant}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.api.game.{
ClockState,
CorrespondenceClockState,
DrawReason,
GameContext,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
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}
import de.nowchess.api.rules.RuleSet
import scala.concurrent.{ExecutionContext, Future}
import java.time.Instant
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
@@ -18,10 +28,11 @@ import scala.concurrent.{ExecutionContext, Future}
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet,
val participants: Map[Color, Participant] = Map(
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
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 =
@@ -31,25 +42,42 @@ class GameEngine(
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard
@SuppressWarnings(Array("DisableSyntax.var"))
private var pendingDrawOffer: Option[Color] = None
private val invoker = new CommandInvoker()
private var pendingDrawOffer: Option[Color] = initialDrawOffer
@SuppressWarnings(Array("DisableSyntax.var"))
private var clockState: Option[ClockState] =
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()
@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
private implicit val ec: ExecutionContext = ExecutionContext.global
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
clockState.foreach(scheduleExpiryCheck)
// Synchronized accessors for current state
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext)
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext)
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
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.
@@ -126,13 +154,17 @@ class GameEngine(
def redo(): Unit = synchronized(performRedo())
/** Resign from the game. The opponent wins. */
def resign(): Unit = synchronized(resign(currentContext.turn))
def resign(color: Color): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite)))
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
pendingDrawOffer = None
invoker.clear()
pendingTakebackRequest = None
stopClock()
redoStack = Nil
notifyObservers(ResignEvent(currentContext, color))
}
@@ -162,7 +194,9 @@ class GameEngine(
case Some(_) =>
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
pendingDrawOffer = None
invoker.clear()
pendingTakebackRequest = None
stopClock()
redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
}
@@ -187,11 +221,13 @@ class GameEngine(
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
stopClock()
redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
stopClock()
redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
}
@@ -199,40 +235,44 @@ class GameEngine(
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
*/
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
def loadGame(importer: GameContextImport, input: String): Either[GameError, Unit] = synchronized {
importer.importGameContext(input) match
case Left(err) => Left(err)
case Right(ctx) =>
replayGame(ctx).map { _ =>
pendingDrawOffer = None
pendingTakebackRequest = None
redoStack = Nil
stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(PgnLoadedEvent(currentContext))
}
}
private def replayGame(ctx: GameContext): Either[String, Unit] =
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)
Right(())
else replayMoves(ctx.moves, savedContext)
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[GameError, Unit] =
val result = moves.foldLeft[Either[GameError, Unit]](Right(())) { (acc, move) =>
acc.flatMap(_ => applyReplayMove(move))
}
result.left.foreach(_ => currentContext = savedContext)
result
private def applyReplayMove(move: Move): Either[String, Unit] =
private def applyReplayMove(move: Move): Either[GameError, Unit] =
val legal = ruleSet.legalMoves(currentContext)(move.from)
val candidate = move.moveType match
case MoveType.Promotion(pp) => legal.find(m => m.to == move.to && m.moveType == MoveType.Promotion(pp))
case _ => legal.find(_.to == move.to)
candidate match
case None => Left("Illegal move.")
case None => Left(GameError.IllegalMove)
case Some(lm) => executeMove(lm); Right(())
/** Export the current game context using the provided exporter. */
@@ -247,7 +287,10 @@ class GameEngine(
else newContext
currentContext = contextWithInitialBoard
pendingDrawOffer = None
invoker.clear()
pendingTakebackRequest = None
redoStack = Nil
stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(BoardResetEvent(currentContext))
}
@@ -255,46 +298,89 @@ class GameEngine(
def reset(): Unit = synchronized {
currentContext = GameContext.initial
pendingDrawOffer = None
invoker.clear()
pendingTakebackRequest = None
redoStack = Nil
stopClock()
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(BoardResetEvent(currentContext))
}
/** Resign the game on behalf of the side to move. */
def resign(): Unit = synchronized {
if currentContext.result.isEmpty then
val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
invoker.clear()
}
/** Apply a draw result directly (for agreement, fifty-move claim, etc.). */
def applyDraw(reason: DrawReason): Unit = synchronized {
if currentContext.result.isEmpty then
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
invoker.clear()
stopClock()
redoStack = Nil
notifyObservers(DrawEvent(currentContext, reason))
}
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
/** Inject clock state directly (for testing). */
private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
// ──── Clock helpers ────
private def advanceClock(movedColor: Color): Unit =
clockState.foreach { cs =>
cs.afterMove(movedColor, Instant.now()) match
case Left(flagged) => clockState = None; cancelScheduled(); handleTimeFlag(flagged)
case Right(updated) => clockState = Some(updated); scheduleExpiryCheck(updated)
}
private def handleTimeFlag(flagged: Color): Unit =
val result =
if ruleSet.isInsufficientMaterial(currentContext) then GameResult.Draw(DrawReason.InsufficientMaterial)
else GameResult.Win(flagged.opposite, WinReason.TimeControl)
currentContext = currentContext.withResult(Some(result))
pendingDrawOffer = None
pendingTakebackRequest = None
redoStack = Nil
notifyObservers(TimeFlagEvent(currentContext, flagged))
private def scheduleExpiryCheck(cs: ClockState): Unit =
cancelScheduled()
cs match
case live: LiveClockState =>
val delayMs = math.max(0L, live.remainingMs(live.activeColor, Instant.now()))
val future = scheduler.schedule(
new Runnable { def run(): Unit = checkClockExpiry() },
delayMs,
TimeUnit.MILLISECONDS,
)
scheduledCheck = Some(future)
case _ => ()
private def cancelScheduled(): Unit =
scheduledCheck.foreach(_.cancel(false))
scheduledCheck = None
private def stopClock(): Unit =
cancelScheduled()
clockState = None
private def checkClockExpiry(): Unit = synchronized {
if currentContext.result.isEmpty then
clockState.foreach { cs =>
if cs.remainingMs(cs.activeColor, Instant.now()) <= 0 then
clockState = None
handleTimeFlag(cs.activeColor)
}
}
// ──── 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)
val notation = translateMoveToNotation(move, contextBefore.board)
currentContext = nextContext
advanceClock(contextBefore.turn)
notifyObservers(
MoveExecutedEvent(
currentContext,
@@ -304,25 +390,28 @@ class GameEngine(
),
)
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear()
else if ruleSet.isStalemate(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear()
else if ruleSet.isInsufficientMaterial(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
invoker.clear()
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
val status = ruleSet.postMoveStatus(currentContext)
if currentContext.result.isEmpty then
if status.isCheckmate then
val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
cancelScheduled()
notifyObservers(CheckmateEvent(currentContext, winner))
redoStack = Nil
else if status.isStalemate then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
redoStack = Nil
else if status.isInsufficientMaterial then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
redoStack = Nil
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if ruleSet.isThreefoldRepetition(currentContext) then
notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
else requestBotMoveIfNeeded()
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
@@ -373,73 +462,67 @@ class GameEngine(
case _ =>
context.board.pieceAt(move.to)
/** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
*/
private def requestBotMoveIfNeeded(): Unit =
val pendingBotMove = synchronized {
participants.get(currentContext.turn) match
case Some(BotParticipant(bot)) => Some((bot, currentContext))
case _ => None
}
pendingBotMove.foreach { case (bot, contextAtRequest) =>
Future {
bot.nextMove(contextAtRequest) match
case Some(move) => applyBotMove(move)
case None => handleBotNoMove()
}
}
private def applyBotMove(move: Move): Unit =
synchronized {
val color = currentContext.turn
val from = move.from
val to = move.to
currentContext.board.pieceAt(from) match
case Some(piece) if piece.color == color =>
val legal = ruleSet.legalMoves(currentContext)(from)
legal.find(m => m.to == to && m.moveType == move.moveType) match
case Some(legalMove) => executeMove(legalMove)
case None =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
case _ =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
}
private def handleBotNoMove(): Unit =
synchronized {
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
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))
}
@@ -0,0 +1,66 @@
package de.nowchess.chess.grpc
import jakarta.inject.Inject
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.*
@GrpcService
@Singleton
class CoordinatorServiceHandler extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
// scalafix:off DisableSyntax.var
@Inject
private var gameSubscriberManager: GameRedisSubscriberManager = uninitialized
// scalafix:on DisableSyntax.var
override def batchResubscribeGames(
request: BatchResubscribeRequest,
responseObserver: StreamObserver[BatchResubscribeResponse],
): Unit =
val count = gameSubscriberManager.batchResubscribeGames(request.getGameIdsList)
val response = BatchResubscribeResponse
.newBuilder()
.setSubscribedCount(count)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
override def unsubscribeGames(
request: UnsubscribeGamesRequest,
responseObserver: StreamObserver[UnsubscribeGamesResponse],
): Unit =
val count = gameSubscriberManager.unsubscribeGames(request.getGameIdsList)
val response = UnsubscribeGamesResponse
.newBuilder()
.setUnsubscribedCount(count)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
override def evictGames(
request: EvictGamesRequest,
responseObserver: StreamObserver[EvictGamesResponse],
): Unit =
val count = gameSubscriberManager.evictGames(request.getGameIdsList)
val response = EvictGamesResponse
.newBuilder()
.setEvictedCount(count)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
override def drainInstance(
request: DrainInstanceRequest,
responseObserver: StreamObserver[DrainInstanceResponse],
): Unit =
val migrated = gameSubscriberManager.drainInstance()
val response = DrainInstanceResponse
.newBuilder()
.setGamesMigrated(migrated)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
@@ -0,0 +1,161 @@
package de.nowchess.chess.grpc
import de.nowchess.api.board.*
import de.nowchess.api.board.CastlingRights as DomainCastlingRights
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
import de.nowchess.api.grpc.ProtoMapperBase
import de.nowchess.api.move.{Move as DomainMove, MoveType}
import de.nowchess.core.proto.*
import scala.jdk.CollectionConverters.*
object CoreProtoMapper
extends ProtoMapperBase[
ProtoColor,
ProtoPieceType,
ProtoMoveKind,
ProtoMove,
ProtoSquarePiece,
java.util.List[ProtoSquarePiece],
ProtoCastlingRights,
ProtoGameResultKind,
ProtoGameContext,
]:
private val (colorTo, colorFrom) = ProtoMapperBase.colorConversions(ProtoColor.WHITE, ProtoColor.BLACK)
private val (pieceTypeTo, pieceTypeFrom) = ProtoMapperBase.pieceTypeConversions(
ProtoPieceType.PAWN,
ProtoPieceType.KNIGHT,
ProtoPieceType.BISHOP,
ProtoPieceType.ROOK,
ProtoPieceType.QUEEN,
ProtoPieceType.KING,
)
private val (moveKindTo, moveKindFrom) = ProtoMapperBase.moveKindConversions(
ProtoMoveKind.QUIET,
ProtoMoveKind.CAPTURE,
ProtoMoveKind.CASTLE_KINGSIDE,
ProtoMoveKind.CASTLE_QUEENSIDE,
ProtoMoveKind.EN_PASSANT,
ProtoMoveKind.PROMO_QUEEN,
ProtoMoveKind.PROMO_ROOK,
ProtoMoveKind.PROMO_BISHOP,
ProtoMoveKind.PROMO_KNIGHT,
)
override def toProtoColor(c: Color): ProtoColor = colorTo(c)
override def fromProtoColor(c: ProtoColor): Color = colorFrom(c)
override def toProtoPieceType(pt: PieceType): ProtoPieceType = pieceTypeTo(pt)
override def fromProtoPieceType(pt: ProtoPieceType): PieceType = pieceTypeFrom(pt)
override def toProtoMoveKind(mt: MoveType): ProtoMoveKind = moveKindTo(mt)
override def fromProtoMoveKind(k: ProtoMoveKind): MoveType = moveKindFrom(k)
override def toProtoMove(m: DomainMove): ProtoMove =
ProtoMove
.newBuilder()
.setFrom(m.from.toString)
.setTo(m.to.toString)
.setMoveKind(toProtoMoveKind(m.moveType))
.build()
override def fromProtoMove(m: ProtoMove): Option[DomainMove] =
for
from <- Square.fromAlgebraic(m.getFrom)
to <- Square.fromAlgebraic(m.getTo)
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
override def toProtoSquarePiece(sq: Square, piece: Piece): ProtoSquarePiece =
ProtoSquarePiece
.newBuilder()
.setSquare(sq.toString)
.setPiece(
ProtoPiece
.newBuilder()
.setColor(toProtoColor(piece.color))
.setPieceType(toProtoPieceType(piece.pieceType))
.build(),
)
.build()
override def fromProtoSquarePiece(sp: ProtoSquarePiece): Option[(Square, Piece)] =
Square
.fromAlgebraic(sp.getSquare)
.map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))
override def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
board.pieces
.map((sq, piece) => toProtoSquarePiece(sq, piece))
.toSeq
.asJava
override def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
Board(
pieces.asScala
.flatMap(fromProtoSquarePiece)
.toMap,
)
override def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
case None => ProtoGameResultKind.ONGOING
case Some(GameResult.Win(Color.White, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_W
case Some(GameResult.Win(Color.Black, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_B
case Some(GameResult.Win(Color.White, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_W
case Some(GameResult.Win(Color.Black, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_B
case Some(GameResult.Win(Color.White, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_W
case Some(GameResult.Win(Color.Black, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_B
case Some(GameResult.Draw(DrawReason.Stalemate)) => ProtoGameResultKind.DRAW_STALEMATE
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => ProtoGameResultKind.DRAW_INSUFFICIENT
case Some(GameResult.Draw(DrawReason.FiftyMoveRule)) => ProtoGameResultKind.DRAW_FIFTY_MOVE
case Some(GameResult.Draw(DrawReason.ThreefoldRepetition)) => ProtoGameResultKind.DRAW_THREEFOLD
case Some(GameResult.Draw(DrawReason.Agreement)) => ProtoGameResultKind.DRAW_AGREEMENT
override def fromProtoResultKind(k: ProtoGameResultKind): Option[GameResult] = k match
case ProtoGameResultKind.ONGOING => None
case ProtoGameResultKind.WIN_CHECKMATE_W => Some(GameResult.Win(Color.White, WinReason.Checkmate))
case ProtoGameResultKind.WIN_CHECKMATE_B => Some(GameResult.Win(Color.Black, WinReason.Checkmate))
case ProtoGameResultKind.WIN_RESIGN_W => Some(GameResult.Win(Color.White, WinReason.Resignation))
case ProtoGameResultKind.WIN_RESIGN_B => Some(GameResult.Win(Color.Black, WinReason.Resignation))
case ProtoGameResultKind.WIN_TIME_W => Some(GameResult.Win(Color.White, WinReason.TimeControl))
case ProtoGameResultKind.WIN_TIME_B => Some(GameResult.Win(Color.Black, WinReason.TimeControl))
case ProtoGameResultKind.DRAW_STALEMATE => Some(GameResult.Draw(DrawReason.Stalemate))
case ProtoGameResultKind.DRAW_INSUFFICIENT => Some(GameResult.Draw(DrawReason.InsufficientMaterial))
case ProtoGameResultKind.DRAW_FIFTY_MOVE => Some(GameResult.Draw(DrawReason.FiftyMoveRule))
case ProtoGameResultKind.DRAW_THREEFOLD => Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
case ProtoGameResultKind.DRAW_AGREEMENT => Some(GameResult.Draw(DrawReason.Agreement))
case _ => None
override def toProtoCastlingRights(cr: DomainCastlingRights): ProtoCastlingRights =
ProtoCastlingRights
.newBuilder()
.setWhiteKingSide(cr.whiteKingSide)
.setWhiteQueenSide(cr.whiteQueenSide)
.setBlackKingSide(cr.blackKingSide)
.setBlackQueenSide(cr.blackQueenSide)
.build()
override def fromProtoCastlingRights(pcr: ProtoCastlingRights): DomainCastlingRights =
DomainCastlingRights(pcr.getWhiteKingSide, pcr.getWhiteQueenSide, pcr.getBlackKingSide, pcr.getBlackQueenSide)
override def toProtoGameContext(ctx: GameContext): ProtoGameContext =
ProtoGameContext
.newBuilder()
.addAllBoard(toProtoBoard(ctx.board))
.setTurn(toProtoColor(ctx.turn))
.setCastlingRights(toProtoCastlingRights(ctx.castlingRights))
.setEnPassantSquare(ctx.enPassantSquare.map(_.toString).getOrElse(""))
.setHalfMoveClock(ctx.halfMoveClock)
.addAllMoves(ctx.moves.map(toProtoMove).asJava)
.setResult(toProtoResultKind(ctx.result))
.addAllInitialBoard(toProtoBoard(ctx.initialBoard))
.build()
override def fromProtoGameContext(p: ProtoGameContext): GameContext =
GameContext(
board = fromProtoBoard(p.getBoardList),
turn = fromProtoColor(p.getTurn),
castlingRights = fromProtoCastlingRights(p.getCastlingRights),
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),
)
@@ -0,0 +1,33 @@
package de.nowchess.chess.grpc
import de.nowchess.api.game.GameContext
import de.nowchess.chess.client.CombinedExportResponse
import de.nowchess.core.proto.*
import io.quarkus.grpc.GrpcClient
import jakarta.enterprise.context.ApplicationScoped
import scala.compiletime.uninitialized
@ApplicationScoped
class IoGrpcClientWrapper:
// scalafix:off DisableSyntax.var
@GrpcClient("io-grpc")
var stub: IoServiceGrpc.IoServiceBlockingStub = uninitialized
// scalafix:on DisableSyntax.var
def exportCombined(ctx: GameContext): CombinedExportResponse =
val combined = stub.exportCombined(CoreProtoMapper.toProtoGameContext(ctx))
CombinedExportResponse(combined.getFen, combined.getPgn)
def importFen(fen: String): GameContext =
CoreProtoMapper.fromProtoGameContext(stub.importFen(ProtoImportFenRequest.newBuilder().setFen(fen).build()))
def importPgn(pgn: String): GameContext =
CoreProtoMapper.fromProtoGameContext(stub.importPgn(ProtoImportPgnRequest.newBuilder().setPgn(pgn).build()))
def exportFen(ctx: GameContext): String =
stub.exportFen(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def exportPgn(ctx: GameContext): String =
stub.exportPgn(CoreProtoMapper.toProtoGameContext(ctx)).getValue
@@ -0,0 +1,74 @@
package de.nowchess.chess.grpc
import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
import de.nowchess.core.proto.*
import io.quarkus.grpc.GrpcClient
import jakarta.enterprise.context.ApplicationScoped
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
@ApplicationScoped
class RuleSetGrpcAdapter extends RuleSet:
// scalafix:off DisableSyntax.var
@GrpcClient("rule-grpc")
var stub: RuleServiceGrpc.RuleServiceBlockingStub = uninitialized
// scalafix:on DisableSyntax.var
def candidateMoves(ctx: GameContext)(sq: Square): List[Move] =
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()
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
def isCheck(ctx: GameContext): Boolean =
stub.isCheck(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def isCheckmate(ctx: GameContext): Boolean =
stub.isCheckmate(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def isStalemate(ctx: GameContext): Boolean =
stub.isStalemate(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def isInsufficientMaterial(ctx: GameContext): Boolean =
stub.isInsufficientMaterial(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def isFiftyMoveRule(ctx: GameContext): Boolean =
stub.isFiftyMoveRule(CoreProtoMapper.toProtoGameContext(ctx)).getValue
def isThreefoldRepetition(ctx: GameContext): Boolean =
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()
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,
)
@@ -1,19 +0,0 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
import de.nowchess.api.move.{MoveType, PromotionPiece}
class MoveTypeDeserializer extends JsonDeserializer[MoveType]:
// scalafix:off DisableSyntax.throw
override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType =
val node = p.getCodec.readTree[ObjectNode](p)
node.get("type").asText() match
case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false))
case "castleKingside" => MoveType.CastleKingside
case "castleQueenside" => MoveType.CastleQueenside
case "enPassant" => MoveType.EnPassant
case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText()))
case t => throw new JsonParseException(p, s"Unknown move type: $t")
// scalafix:on DisableSyntax.throw
@@ -1,23 +0,0 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.move.MoveType
class MoveTypeSerializer extends JsonSerializer[MoveType]:
override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeStartObject()
value match
case MoveType.Normal(isCapture) =>
gen.writeStringField("type", "normal")
gen.writeBooleanField("isCapture", isCapture)
case MoveType.CastleKingside =>
gen.writeStringField("type", "castleKingside")
case MoveType.CastleQueenside =>
gen.writeStringField("type", "castleQueenside")
case MoveType.EnPassant =>
gen.writeStringField("type", "enPassant")
case MoveType.Promotion(piece) =>
gen.writeStringField("type", "promotion")
gen.writeStringField("piece", piece.toString)
gen.writeEndObject()
@@ -1,9 +0,0 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
import de.nowchess.api.board.Square
class SquareDeserializer extends JsonDeserializer[Square]:
override def deserialize(p: JsonParser, ctx: DeserializationContext): Square =
Square.fromAlgebraic(p.getText).orNull
@@ -1,9 +0,0 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.board.Square
class SquareSerializer extends JsonSerializer[Square]:
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeString(value.toString)
@@ -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,
@@ -92,6 +83,24 @@ case class DrawOfferDeclinedEvent(
declinedBy: Color,
) extends GameEvent
/** Fired when a player's clock expires. */
case class TimeFlagEvent(
context: GameContext,
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, playerId: Option[String] = None) extends C2sMessage
case object Ping extends C2sMessage
@@ -0,0 +1,79 @@
package de.nowchess.chess.redis
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.{GameStateEventDto, GameWritebackEventDto}
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
import de.nowchess.chess.grpc.IoGrpcClientWrapper
import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
import de.nowchess.api.board.Color
import de.nowchess.chess.observer.{GameEvent, Observer}
import de.nowchess.chess.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper
import io.quarkus.redis.datasource.RedisDataSource
class GameRedisPublisher(
gameId: String,
registry: GameRegistry,
redis: RedisDataSource,
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))
redis.pubsub(classOf[String]).publish(s2cTopicName, 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),
result = entry.engine.context.result.map {
case GameResult.Win(Color.White, _) => "white"
case GameResult.Win(Color.Black, _) => "black"
case GameResult.Draw(_) => "draw"
},
terminationReason = entry.engine.context.result.map {
case GameResult.Win(_, WinReason.Checkmate) => "checkmate"
case GameResult.Win(_, WinReason.Resignation) => "resignation"
case GameResult.Win(_, WinReason.TimeControl) => "timeout"
case GameResult.Draw(DrawReason.Stalemate) => "stalemate"
case GameResult.Draw(DrawReason.InsufficientMaterial) => "insufficient_material"
case GameResult.Draw(DrawReason.FiftyMoveRule) => "fifty_move"
case GameResult.Draw(DrawReason.ThreefoldRepetition) => "repetition"
case GameResult.Draw(DrawReason.Agreement) => "agreement"
},
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,149 @@
package de.nowchess.chess.redis
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.board.Color
import de.nowchess.api.dto.GameFullEventDto
import de.nowchess.api.game.GameMode
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 de.nowchess.chess.service.InstanceHeartbeatService
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Instance
import jakarta.inject.Inject
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@ApplicationScoped
class GameRedisSubscriberManager:
// scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized
@Inject var registry: GameRegistry = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
@Inject var heartbeatServiceInstance: Instance[InstanceHeartbeatService] = uninitialized
// scalafix:on DisableSyntax.var
private def heartbeatServiceOpt: Option[InstanceHeartbeatService] =
if heartbeatServiceInstance.isUnsatisfied then None
else Some(heartbeatServiceInstance.get())
private val c2sListeners = new ConcurrentHashMap[String, PubSubCommands.RedisSubscriber]()
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 handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
val subscriber = redis.pubsub(classOf[String]).subscribe(c2sTopic(gameId), handler)
c2sListeners.put(gameId, subscriber)
val writebackFn: String => Unit = json => redis.pubsub(classOf[String]).publish("game-writeback", json)
val obs = new GameRedisPublisher(
gameId,
registry,
redis,
objectMapper,
s2cTopicName(gameId),
writebackFn,
ioClient,
unsubscribeGame,
)
s2cObservers.put(gameId, obs)
registry.get(gameId).foreach(_.engine.subscribe(obs))
heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
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 { subscriber =>
subscriber.unsubscribe(c2sTopic(gameId))
}
Option(s2cObservers.remove(gameId)).foreach { obs =>
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
}
heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
private def handleC2sMessage(gameId: String, msg: String): Unit =
parseC2sMessage(msg) match
case Some(C2sMessage.Connected) => handleConnected(gameId)
case Some(C2sMessage.Move(uci, playerId)) => handleMove(gameId, uci, playerId)
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))
redis.pubsub(classOf[String]).publish(s2cTopicName(gameId), json)
}
private def handleMove(gameId: String, uci: String, playerId: Option[String]): Unit =
registry.get(gameId).foreach { entry =>
entry.mode match
case GameMode.Open => entry.engine.processUserInput(uci)
case GameMode.Authenticated =>
playerId match
case None => ()
case Some(pid) =>
val turn = entry.engine.context.turn
val authorised =
(entry.white.id.value == pid && turn == Color.White) ||
(entry.black.id.value == pid && turn == Color.Black)
if authorised then 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 =>
val pid = Option(node.get("playerId")).map(_.asText()).filter(_.nonEmpty)
C2sMessage.Move(u.asText(), pid)
}
case "PING" => Some(C2sMessage.Ping)
case _ => None
}
}
def batchResubscribeGames(gameIds: java.util.List[String]): Int =
gameIds.forEach(subscribeGame)
gameIds.size()
def unsubscribeGames(gameIds: java.util.List[String]): Int =
gameIds.forEach(unsubscribeGame)
gameIds.size()
def evictGames(gameIds: java.util.List[String]): Int =
gameIds.forEach(unsubscribeGame)
gameIds.size()
def drainInstance(): Int =
val gameIds = new java.util.ArrayList(c2sListeners.keySet())
val count = gameIds.size()
gameIds.forEach(unsubscribeGame)
count
@PreDestroy
def cleanup(): Unit =
c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)))
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
@@ -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,6 +1,7 @@
package de.nowchess.chess.registry
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameMode
import de.nowchess.api.player.PlayerInfo
import de.nowchess.chess.engine.GameEngine
@@ -10,4 +11,5 @@ final case class GameEntry(
white: PlayerInfo,
black: PlayerInfo,
resigned: Boolean = false,
mode: GameMode = GameMode.Open,
)
@@ -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,204 @@
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 io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
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
@ApplicationScoped
class RedisGameRegistry extends GameRegistry:
@Inject
// scalafix:off DisableSyntax.var
var redis: RedisDataSource = 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"
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)
redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
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)
redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
private def readRedisDto(gameId: String): Option[GameCacheDto] =
Try(Option(redis.value(classOf[String]).get(cacheKey(gameId)))).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)
redis.value(classOf[String]).setex(cacheKey(gameId), 1800L, objectMapper.writeValueAsString(dto))
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, info.playerType)
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))
@@ -1,24 +1,35 @@
package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.board.Square
import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.dto.*
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
import de.nowchess.api.game.{
CorrespondenceClockState,
DrawReason,
GameContext,
GameMode,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.adapter.RuleSetRestAdapter
import de.nowchess.chess.client.IoServiceClient
import java.time.Instant
import de.nowchess.api.rules.RuleSet
import de.nowchess.chess.controller.Parser
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 io.smallrye.mutiny.Multi
import de.nowchess.security.InternalOnly
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.eclipse.microprofile.jwt.JsonWebToken
import java.util.concurrent.atomic.AtomicReference
import scala.compiletime.uninitialized
@@ -35,42 +46,48 @@ class GameResource:
var objectMapper: ObjectMapper = uninitialized
@Inject
@RestClient
var ioClient: IoServiceClient = uninitialized
var ioClient: IoGrpcClientWrapper = uninitialized
@Inject
var ruleSetAdapter: RuleSetRestAdapter = uninitialized
var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
@Inject
var subscriberManager: GameRedisSubscriberManager = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
// ── auth helpers ─────────────────────────────────────────────────────────
// scalafix:off DisableSyntax.throw
private def colorOf(entry: GameEntry): Color =
entry.mode match
case GameMode.Open => entry.engine.context.turn
case GameMode.Authenticated =>
val subject = Option(jwt)
.flatMap(j => Option(j.getSubject))
.getOrElse(throw ForbiddenException("Authentication required"))
if entry.white.id.value == subject then Color.White
else if entry.black.id.value == subject then Color.Black
else throw ForbiddenException("You are not a player in this game")
private def assertIsCurrentPlayer(entry: GameEntry): Unit =
if entry.mode == GameMode.Authenticated then
val color = colorOf(entry)
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
private def assertIsNotBot(): Unit =
val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
if Set("bot", "official-bot").contains(botType) then throw ForbiddenException("Only bots can make moves")
// scalafix:on DisableSyntax.throw
// ── 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(_)) =>
if entry.resigned then "resign" else "checkmate"
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)
@@ -82,32 +99,34 @@ 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 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,
)
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))
private def newEntry(ctx: GameContext, white: PlayerInfo, black: PlayerInfo): GameEntry =
GameEntry(registry.generateId(), GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter), white, black)
private def toTimeControl(dto: Option[TimeControlDto]): TimeControl =
dto match
case None => TimeControl.Unlimited
case Some(tc) =>
tc.daysPerMove match
case Some(d) => TimeControl.Correspondence(d)
case None =>
tc.limitSeconds.fold(TimeControl.Unlimited)(l => TimeControl.Clock(l, tc.incrementSeconds.getOrElse(0)))
private def newEntry(
ctx: GameContext,
white: PlayerInfo,
black: PlayerInfo,
tc: TimeControl = TimeControl.Unlimited,
mode: GameMode = GameMode.Open,
): GameEntry =
GameEntry(
registry.generateId(),
GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter, timeControl = tc),
white,
black,
mode = mode,
)
private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
val error = new AtomicReference[Option[String]](None)
@@ -134,43 +153,27 @@ class GameResource:
// scalafix:off DisableSyntax.throw
@POST
@InternalOnly
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(body: CreateGameRequestDto): Response =
val req = Option(body).getOrElse(CreateGameRequestDto(None, None))
val req = Option(body).getOrElse(CreateGameRequestDto(None, None, None, None))
val white = playerInfoFrom(req.white, DefaultWhite)
val black = playerInfoFrom(req.black, DefaultBlack)
val entry = newEntry(GameContext.initial, white, black)
val tc = toTimeControl(req.timeControl)
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))
@GET
@Path("/{gameId}/stream")
@Produces(Array("application/x-ndjson"))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
Multi
.createFrom()
.emitter[String] { emitter =>
emitter.emit(objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry))) + "\n")
val obs = new Observer:
def onGameEvent(event: GameEvent): Unit =
registry.get(gameId).foreach { updated =>
emitter.emit(
objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))) + "\n",
)
}
entry.engine.subscribe(obs)
emitter.onTermination(() => entry.engine.unsubscribe(obs))
}
ok(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST
@Path("/{gameId}/resign")
@@ -178,7 +181,8 @@ class GameResource:
def resignGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
entry.engine.resign()
val color = colorOf(entry)
entry.engine.resign(color)
registry.update(entry.copy(resigned = true))
ok(OkResponseDto())
@@ -186,17 +190,15 @@ class GameResource:
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
assertIsNotBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
val (from, to, promoOpt) = Parser
.parseMove(uci)
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
val candidates = entry.engine.ruleSet.legalMoves(entry.engine.context)(from).filter(_.to == to)
val isPromotion = candidates.exists { case Move(_, _, MoveType.Promotion(_)) => true; case _ => false }
if candidates.isEmpty || (isPromotion && promoOpt.isEmpty) then
throw BadRequestException("INVALID_MOVE", s"$uci is not a legal move", Some("uci"))
assertIsCurrentPlayer(entry)
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")
@@ -223,7 +225,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")
@@ -232,7 +235,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}")
@@ -243,43 +247,55 @@ class GameResource:
): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
val color = colorOf(entry)
action match
case "offer" =>
entry.engine.offerDraw(entry.engine.context.turn)
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.acceptDraw(entry.engine.context.turn)
ok(OkResponseDto())
case "decline" =>
entry.engine.declineDraw(entry.engine.context.turn)
ok(OkResponseDto())
case "claim" =>
entry.engine.claimDraw()
ok(OkResponseDto())
case _ =>
throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
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))
@Produces(Array(MediaType.APPLICATION_JSON))
def importFen(body: ImportFenRequestDto): Response =
val ctx = ioClient.importFen(ImportFenRequest(body.fen))
val ctx = ioClient.importFen(body.fen)
val white = playerInfoFrom(body.white, DefaultWhite)
val black = playerInfoFrom(body.black, DefaultBlack)
val entry = newEntry(ctx, white, black)
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")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importPgn(body: ImportPgnRequestDto): Response =
val ctx = ioClient.importPgn(ImportPgnRequest(body.pgn))
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")
@@ -0,0 +1,206 @@
package de.nowchess.chess.service
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import io.quarkus.runtime.StartupEvent
import io.quarkus.runtime.ShutdownEvent
import io.quarkus.grpc.GrpcClient
import org.eclipse.microprofile.config.inject.ConfigProperty
import io.quarkus.redis.datasource.RedisDataSource
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.proto.{CoordinatorServiceGrpc, *}
import de.nowchess.coordinator.proto.CoordinatorServiceGrpc.CoordinatorServiceStub
import io.grpc.stub.StreamObserver
import io.grpc.Channel
@ApplicationScoped
class InstanceHeartbeatService:
// scalafix:off DisableSyntax.var
@Inject
private var redis: RedisDataSource = uninitialized
@GrpcClient("coordinator-grpc")
private var channel: Channel = uninitialized
@ConfigProperty(name = "quarkus.http.port", defaultValue = "8080")
private var httpPort: Int = 0
@ConfigProperty(name = "quarkus.grpc.server.port", defaultValue = "9000")
private var grpcPort: Int = 0
@ConfigProperty(name = "nowchess.coordinator.enabled", defaultValue = "true")
private var coordinatorEnabled: Boolean = true
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 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 serviceActive = false
private var shuttingDown = false
// scalafix:on DisableSyntax.var
def onStart(@Observes event: StartupEvent): Unit =
if coordinatorEnabled then
try
shuttingDown = false
generateInstanceId()
initializeHeartbeatStream()
scheduleHeartbeats()
serviceActive = true
log.infof("Instance heartbeat service started with ID: %s", instanceId)
catch
case ex: Exception =>
serviceActive = false
log.errorf(ex, "Failed to start instance heartbeat service")
else log.info("Coordinator support disabled via config; skipping heartbeat service startup")
def onShutdown(@Observes event: ShutdownEvent): Unit =
shuttingDown = true
if serviceActive then
try
cleanup()
serviceActive = false
log.info("Instance heartbeat service stopped")
catch
case ex: Exception =>
log.errorf(ex, "Error during heartbeat service shutdown")
else log.info("Instance heartbeat service stopped")
def setRedisPrefix(prefix: String): Unit =
redisPrefix = prefix
def setSubscriptionCount(count: Int): Unit =
subscriptionCount = count
def setLocalCacheSize(count: Int): Unit =
localCacheSize = count
def addGameSubscription(gameId: String): Unit =
if coordinatorEnabled then
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.set(classOf[String]).sadd(setKey, gameId)
subscriptionCount += 1
def removeGameSubscription(gameId: String): Unit =
if coordinatorEnabled then
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.set(classOf[String]).srem(setKey, gameId)
subscriptionCount = Math.max(0, subscriptionCount - 1)
private def generateInstanceId(): Unit =
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
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")
streamObserver = None
if !shuttingDown then
heartbeatExecutor.schedule((() => initializeHeartbeatStream()): Runnable, 5, TimeUnit.SECONDS)
override def onCompleted: Unit =
log.info("Heartbeat stream completed")
streamObserver = Some(coordinatorStub.heartbeatStream(responseObserver))
log.info("Connected to coordinator heartbeat stream")
catch
case ex: Exception =>
log.warnf(ex, "Failed to connect to coordinator")
streamObserver = None
private def scheduleHeartbeats(): Unit =
heartbeatExecutor.scheduleAtFixedRate(
() => sendHeartbeat(),
0,
200,
TimeUnit.MILLISECONDS,
)
redisHeartbeatExecutor.scheduleAtFixedRate(
() => refreshRedisHeartbeat(),
0,
2,
TimeUnit.SECONDS,
)
private def sendHeartbeat(): Unit =
streamObserver.foreach { observer =>
try
val frame = HeartbeatFrame
.newBuilder()
.setInstanceId(instanceId)
.setHostname(getHostname)
.setHttpPort(httpPort)
.setGrpcPort(grpcPort)
.setSubscriptionCount(subscriptionCount)
.setLocalCacheSize(localCacheSize)
.setTimestampMillis(System.currentTimeMillis())
.build()
observer.onNext(frame)
catch
case ex: Exception =>
log.warnf(ex, "Failed to send heartbeat frame")
}
private def refreshRedisHeartbeat(): Unit =
try
val key = s"$redisPrefix:instances:$instanceId"
val metadata = Map(
"instanceId" -> instanceId,
"hostname" -> getHostname,
"httpPort" -> httpPort,
"grpcPort" -> grpcPort,
"subscriptionCount" -> subscriptionCount,
"localCacheSize" -> localCacheSize,
"lastHeartbeat" -> java.time.Instant.now().toString,
"state" -> "HEALTHY",
)
val json = mapper.writeValueAsString(metadata)
redis.value(classOf[String]).setex(key, 5L, json)
catch
case ex: Exception =>
log.warnf(ex, "Failed to refresh Redis heartbeat")
private def getHostname: String =
try InetAddress.getLocalHost.getHostName
catch case _: Exception => "unknown"
private def cleanup(): Unit =
streamObserver.foreach(_.onCompleted())
streamObserver = None
if instanceId.nonEmpty then
val key = s"$redisPrefix:instances:$instanceId"
redis.key(classOf[String]).del(key)
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.key(classOf[String]).del(setKey)
heartbeatExecutor.shutdown()
redisHeartbeatExecutor.shutdown()
if !heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then heartbeatExecutor.shutdownNow()
if !redisHeartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then redisHeartbeatExecutor.shutdownNow()
@@ -0,0 +1,24 @@
quarkus:
grpc:
clients:
rule-grpc:
host: localhost
port: 9082
io-grpc:
host: localhost
port: 9081
rest-client:
store-service:
url: http://localhost:8085
nowchess:
internal:
secret: test-secret
auth:
enabled: false
coordinator:
enabled: false
redis:
host: localhost
port: 6379
prefix: test-core
@@ -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
@@ -0,0 +1,17 @@
package de.nowchess.chess.config
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.Priority
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Alternative
import jakarta.enterprise.inject.Produces
import org.mockito.Mockito
@Alternative
@Priority(1)
@ApplicationScoped
class MockRedisDataSourceProducer:
@Produces
@ApplicationScoped
def produceRedisDataSource(): RedisDataSource =
Mockito.mock(classOf[RedisDataSource], Mockito.RETURNS_DEEP_STUBS)
@@ -0,0 +1,155 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.{
ClockState,
CorrespondenceClockState,
DrawReason,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.time.Instant
import java.time.temporal.ChronoUnit
class GameEngineClockTest extends AnyFunSuite with Matchers:
private def makeClockEngine(tc: TimeControl): GameEngine =
new GameEngine(ruleSet = DefaultRules, timeControl = tc)
// ── Unlimited ─────────────────────────────────────────────────────────────
test("Unlimited time control: no clock state"):
val engine = makeClockEngine(TimeControl.Unlimited)
engine.currentClockState shouldBe None
// ── Live clock initialisation ─────────────────────────────────────────────
test("Clock(300,3) initialises both sides to 300,000ms"):
val engine = makeClockEngine(TimeControl.Clock(300, 3))
engine.currentClockState match
case Some(cs: LiveClockState) =>
cs.whiteRemainingMs shouldBe 300_000L
cs.blackRemainingMs shouldBe 300_000L
cs.incrementMs shouldBe 3_000L
cs.activeColor shouldBe Color.White
case other => fail(s"Expected Some(LiveClockState), got $other")
// ── Clock advances after move ─────────────────────────────────────────────
test("After White move, activeColor flips to Black and white time decreases"):
val engine = makeClockEngine(TimeControl.Clock(300, 3))
engine.processUserInput("e2e4")
engine.currentClockState match
case Some(cs: LiveClockState) =>
cs.activeColor shouldBe Color.Black
cs.whiteRemainingMs should be < 300_000L + 3_000L
cs.blackRemainingMs shouldBe 300_000L
case other => fail(s"Expected Some(LiveClockState), got $other")
// ── Time flag via injection ───────────────────────────────────────────────
test("TimeFlagEvent fires and result is Win(opponent) when White flags on move"):
val engine = makeClockEngine(TimeControl.Clock(300, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Inject nearly-exhausted clock: White has 1ms, will flag on move
val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
engine.injectClockState(Some(expiredClock))
engine.processUserInput("e2e4")
observer.hasEvent[TimeFlagEvent] shouldBe true
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
engine.context.result shouldBe Some(GameResult.Win(Color.Black, WinReason.TimeControl))
test("TimeFlagEvent fires and result is Win(Black) when Black flags on move"):
val engine = makeClockEngine(TimeControl.Clock(300, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
observer.clear()
val expiredClock = LiveClockState(300_000L, 1L, 0L, Instant.now().minusSeconds(10), Color.Black)
engine.injectClockState(Some(expiredClock))
engine.processUserInput("e7e5")
observer.hasEvent[TimeFlagEvent] shouldBe true
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.Black)
engine.context.result shouldBe Some(GameResult.Win(Color.White, WinReason.TimeControl))
test("Flag with insufficient material gives Draw(InsufficientMaterial)"):
// King vs King — White flags but Black can't mate
// White king e4, Black king e6: e4d3 is a legal move (not adjacent to e6)
val engine = makeClockEngine(TimeControl.Clock(300, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "8/8/4k3/8/4K3/8/8/8 w - - 0 1")
observer.clear()
val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
engine.injectClockState(Some(expiredClock))
engine.processUserInput("e4d3")
observer.hasEvent[TimeFlagEvent] shouldBe true
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
// ── Correspondence clock ──────────────────────────────────────────────────
test("Correspondence(3): after move, deadline is ~3 days from move time"):
val engine = makeClockEngine(TimeControl.Correspondence(3))
val before = Instant.now()
engine.processUserInput("e2e4")
val after = Instant.now()
engine.currentClockState match
case Some(cs: CorrespondenceClockState) =>
val expectedMin = before.plus(3L, ChronoUnit.DAYS)
val expectedMax = after.plus(3L, ChronoUnit.DAYS)
cs.moveDeadline.isAfter(expectedMin.minusSeconds(1)) shouldBe true
cs.moveDeadline.isBefore(expectedMax.plusSeconds(1)) shouldBe true
cs.activeColor shouldBe Color.Black
case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
test("Correspondence flag fires TimeFlagEvent when move past deadline"):
val engine = makeClockEngine(TimeControl.Correspondence(3))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Inject expired deadline
val expired = CorrespondenceClockState(Instant.now().minusSeconds(60), 3, Color.White)
engine.injectClockState(Some(expired))
engine.processUserInput("e2e4")
observer.hasEvent[TimeFlagEvent] shouldBe true
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
// ── reset() restarts clock ────────────────────────────────────────────────
test("reset() restarts clock to full time"):
val engine = makeClockEngine(TimeControl.Clock(300, 3))
engine.processUserInput("e2e4")
engine.reset()
engine.currentClockState match
case Some(cs: LiveClockState) =>
cs.whiteRemainingMs shouldBe 300_000L
cs.blackRemainingMs shouldBe 300_000L
cs.activeColor shouldBe Color.White
case other => fail(s"Expected Some(LiveClockState), got $other")
// ── Passive expiry via scheduler ──────────────────────────────────────────
test("Scheduler fires TimeFlagEvent when active player's clock expires passively"):
// Scheduler starts on engine creation, so TimeFlagEvent fires without a move being made
val engine = new GameEngine(ruleSet = DefaultRules, timeControl = TimeControl.Clock(1, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
Thread.sleep(1500)
observer.hasEvent[TimeFlagEvent] shouldBe true
@@ -202,7 +202,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events.clear()
engine.resign(Color.Black)
engine.resign()
// Try to accept the now-cleared draw offer
observer.events.clear()
@@ -222,7 +222,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events.clear()
engine.resign(Color.Black)
engine.resign()
// Try to accept the now-cleared draw offer
observer.events.clear()
@@ -3,7 +3,8 @@ 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
import de.nowchess.rules.sets.DefaultRules
@@ -20,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)
@@ -58,9 +50,9 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val engine = new GameEngine(ruleSet = DefaultRules)
val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
def importGameContext(input: String): Either[GameError, GameContext] = Left(GameError.ParseError("boom"))
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left(GameError.ParseError("boom"))
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine(ruleSet = DefaultRules)
@@ -71,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))
@@ -109,7 +86,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val engine = new GameEngine(ruleSet = permissiveRules)
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
engine.loadGame(importer, "ignored") shouldBe Right(())
@@ -134,13 +111,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val saved = engine.context
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
val result = engine.loadGame(importer, "ignored")
result.isLeft shouldBe true
result.left.toOption.get should include("Illegal move")
result shouldBe Left(GameError.IllegalMove)
engine.context shouldBe saved
test("loadGame replay executes non-promotion moves through default replay branch"):
@@ -156,7 +132,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4"))
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Illegal move.")
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left(GameError.IllegalMove)
engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"):
@@ -1,6 +1,7 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.WinReason.Checkmate
import de.nowchess.api.game.{DrawReason, GameResult}
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
@@ -23,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("d8h4")
observer.hasEvent[CheckmateEvent] shouldBe true
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
engine.context.result shouldBe Some(GameResult.Win(Color.Black, Checkmate))
test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine()
@@ -43,7 +44,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val evt = observer.getEvent[CheckmateEvent]
evt.isDefined shouldBe true
evt.get.winner shouldBe Color.White
engine.context.result shouldBe Some(GameResult.Win(Color.White))
engine.context.result shouldBe Some(GameResult.Win(Color.White, Checkmate))
// ── Stalemate ───────────────────────────────────────────────────
@@ -1,9 +1,12 @@
package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.board.Color.{Black, White}
import de.nowchess.api.game.GameResult
import de.nowchess.api.game.WinReason.Resignation
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer, ResignEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -15,13 +18,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign(Color.White)
engine.resign(White)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.White
event.context.result shouldBe Some(GameResult.Win(Color.Black))
event.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
case other =>
fail(s"Expected ResignEvent, but got $other")
@@ -30,13 +33,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign(Color.Black)
engine.resign(Black)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.Black
event.context.result shouldBe Some(GameResult.Win(Color.White))
event.context.result shouldBe Some(GameResult.Win(Color.White, Resignation))
case other =>
fail(s"Expected ResignEvent, but got $other")
@@ -54,7 +57,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
// Try to resign
observer.events.clear()
engine.resign(Color.White)
engine.resign()
// Should get InvalidMoveEvent with GameAlreadyOver reason
observer.events.length shouldBe 1
@@ -71,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
engine.resign()
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
engine.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
test("resign() without color does nothing when game already over"):
val engine = new GameEngine(ruleSet = DefaultRules)
@@ -1,266 +0,0 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.{BotParticipant, GameContext, Human}
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.{BotController, BotDifficulty}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
private class NoMoveBot extends Bot:
def name: String = "nomove"
def nextMove(context: GameContext): Option[Move] = None
private class FixedMoveBot(move: Move) extends Bot:
def name: String = "fixed"
def nextMove(context: GameContext): Option[Move] = Some(move)
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
test("GameEngine can play against a ClassicalBot"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
// Collect events
val moveCount = new AtomicInteger(0)
val checkmateDetected = new AtomicBoolean(false)
val gameEnded = new AtomicBoolean(false)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent =>
moveCount.incrementAndGet()
case _: CheckmateEvent =>
checkmateDetected.set(true)
gameEnded.set(true)
case _: DrawEvent =>
gameEnded.set(true)
case _ => ()
engine.subscribe(observer)
// Play a few moves: e2e4, then let the bot respond
engine.processUserInput("e2e4")
// Wait a bit for the bot to respond asynchronously
Thread.sleep(5000)
// White should have moved, then Black (bot) should have responded
moveCount.get() should be >= 2
test("BotController can list and retrieve bots"):
val bots = BotController.listBots
bots should contain("easy")
bots should contain("medium")
bots should contain("hard")
bots should contain("expert")
BotController.getBot("easy") should not be None
BotController.getBot("medium") should not be None
BotController.getBot("hard") should not be None
BotController.getBot("expert") should not be None
BotController.getBot("unknown") should be(None)
test("GameEngine handles bot with different difficulty"):
val hardBot = BotController.getBot("hard").get
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
)
engine.turn should equal(Color.White)
val movesMade = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// White moves
engine.processUserInput("d2d4")
Thread.sleep(500) // Wait for bot response
// At least white moved, possibly black also responded
movesMade.get() should be >= 1
test("GameEngine plays valid bot moves"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val moveCount = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => moveCount.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// Play a normal move
engine.processUserInput("e2e4")
Thread.sleep(1000)
// The game should have progressed with at least one move
moveCount.get() should be >= 1
// Game should not be ended (checkmate/stalemate)
engine.context.moves.nonEmpty should be(true)
test("startGame triggers bot when the starting player is a bot"):
val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val movesMade = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
movesMade.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
val bot = new FixedMoveBot(illegalMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
val bot = new FixedMoveBot(invalidMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
// White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R2) -> Piece.BlackQueen,
Square(File.B, Rank.R8) -> Piece.BlackRook,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val checkmateCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => checkmateCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
checkmateCount.get() should be >= 1
test("handleBotNoMove fires DrawEvent when position is stalemate"):
// White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R3) -> Piece.BlackQueen,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val drawCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: DrawEvent => drawCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
drawCount.get() should be >= 1
test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val unexpectedEvents = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
case _: DrawEvent => unexpectedEvents.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
unexpectedEvents.get() shouldBe 0
@@ -1,87 +1,31 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.{MoveType, PromotionPiece}
import de.nowchess.api.move.MoveType
import de.nowchess.chess.config.JacksonConfig
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonSerializersTest extends AnyFunSuite with Matchers:
private val mapper: ObjectMapper =
val m = new ObjectMapper()
val mod = new SimpleModule()
m.registerModule(DefaultScalaModule)
mod.addSerializer(classOf[Square], new SquareSerializer())
mod.addDeserializer(classOf[Square], new SquareDeserializer())
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
m.registerModule(mod)
val m = new ObjectMapper()
new JacksonConfig().customize(m)
m
private val e4 = Square(File.E, Rank.R4)
test("customize enables Option serialization via DefaultScalaModule"):
mapper.writeValueAsString(None) shouldBe "null"
mapper.writeValueAsString(Some("hello")) shouldBe """"hello""""
// ── SquareSerializer ──────────────────────────────────────────────
test("customize registers SquareSerializer"):
mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
test("SquareSerializer writes square as string"):
mapper.writeValueAsString(e4) shouldBe """"e4""""
test("customize registers SquareDeserializer"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
// ── SquareDeserializer ────────────────────────────────────────────
test("SquareDeserializer reads valid square string"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
// scalafix:off DisableSyntax.null
test("SquareDeserializer returns null for invalid square string"):
mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
// scalafix:on DisableSyntax.null
// ── MoveTypeSerializer ────────────────────────────────────────────
test("MoveTypeSerializer serializes Normal non-capture"):
mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
test("MoveTypeSerializer serializes Normal capture"):
mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
test("MoveTypeSerializer serializes CastleKingside"):
test("customize registers MoveTypeSerializer"):
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
test("MoveTypeSerializer serializes CastleQueenside"):
mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
test("MoveTypeSerializer serializes EnPassant"):
mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
test("MoveTypeSerializer serializes Promotion"):
mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
"""{"type":"promotion","piece":"Queen"}"""
// ── MoveTypeDeserializer ──────────────────────────────────────────
test("MoveTypeDeserializer deserializes normal non-capture"):
mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe
MoveType.Normal(false)
test("MoveTypeDeserializer deserializes normal capture"):
mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe
MoveType.Normal(true)
test("MoveTypeDeserializer deserializes castleKingside"):
mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
test("MoveTypeDeserializer deserializes castleQueenside"):
mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
test("MoveTypeDeserializer deserializes enPassant"):
test("customize registers MoveTypeDeserializer"):
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
test("MoveTypeDeserializer deserializes promotion"):
mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
MoveType.Promotion(PromotionPiece.Rook)
test("MoveTypeDeserializer throws for unknown type"):
an[Exception] should be thrownBy
mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
@@ -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 =
@@ -3,15 +3,16 @@ package de.nowchess.chess.resource
import de.nowchess.api.board.Square
import de.nowchess.api.dto.*
import de.nowchess.api.game.GameContext
import de.nowchess.chess.client.{IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
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
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.eclipse.microprofile.jwt.JsonWebToken
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
@@ -29,52 +30,71 @@ class GameResourceIntegrationTest:
var resource: GameResource = uninitialized
@InjectMock
@RestClient
var ioClient: IoServiceClient = uninitialized
var ruleAdapter: RuleSetGrpcAdapter = uninitialized
@InjectMock
@RestClient
var ruleClient: RuleServiceClient = uninitialized
var ioWrapper: IoGrpcClientWrapper = uninitialized
@InjectMock
var jwt: JsonWebToken = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(ioClient.importFen(any())).thenReturn(GameContext.initial)
when(ioClient.importPgn(any())).thenReturn(
PgnParser.importGameContext("1. e4 c5").toOption.get,
when(jwt.getClaim[AnyRef]("type")).thenReturn("user")
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
)
when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
when(ioClient.exportPgn(any())).thenReturn("1. e4 c5")
when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
val req = inv.getArgument[RuleSquareRequest](0)
DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get),
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
)
when(ruleClient.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
when(ioWrapper.exportFen(any())).thenAnswer((inv: InvocationOnMock) =>
FenExporter.exportGameContext(inv.getArgument[GameContext](0)),
)
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)),
)
when(ruleAdapter.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.allLegalMoves(inv.getArgument[GameContext](0)),
)
when(ruleClient.applyMove(any())).thenAnswer((inv: InvocationOnMock) =>
val req = inv.getArgument[RuleMoveRequest](0)
DefaultRules.applyMove(req.context)(req.move),
when(ruleAdapter.applyMove(any())(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.applyMove(inv.getArgument[GameContext](0))(inv.getArgument[de.nowchess.api.move.Move](1)),
)
when(ruleClient.isCheck(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isCheck(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isCheck(inv.getArgument[GameContext](0)),
)
when(ruleClient.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isCheckmate(inv.getArgument[GameContext](0)),
)
when(ruleClient.isStalemate(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isStalemate(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isStalemate(inv.getArgument[GameContext](0)),
)
when(ruleClient.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isInsufficientMaterial(inv.getArgument[GameContext](0)),
)
when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)),
)
when(ruleAdapter.postMoveStatus(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.postMoveStatus(inv.getArgument[GameContext](0)),
)
when(ruleAdapter.candidateMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.candidateMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
)
when(ruleAdapter.isFiftyMoveRule(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isFiftyMoveRule(inv.getArgument[GameContext](0)),
)
@Test
@DisplayName("createGame returns 201")
def testCreateGame(): Unit =
val req = CreateGameRequestDto(None, None)
val req = CreateGameRequestDto(None, None, None)
val resp = resource.createGame(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
@@ -83,7 +103,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("getGame returns 200")
def testGetGame(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val getResp = resource.getGame(gameId)
assertEquals(200, getResp.getStatus)
@@ -93,7 +113,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("makeMove advances game")
def testMakeMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val moveResp = resource.makeMove(gameId, "e2e4")
assertEquals(200, moveResp.getStatus)
@@ -103,14 +123,14 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("makeMove with invalid UCI throws")
def testMakeMoveInvalid(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
@Test
@DisplayName("getLegalMoves returns moves")
def testGetLegalMoves(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "")
assertEquals(200, movesResp.getStatus)
@@ -120,7 +140,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("resignGame updates state")
def testResignGame(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resignResp = resource.resignGame(gameId)
assertEquals(200, resignResp.getStatus)
@@ -131,7 +151,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("undoMove reverts")
def testUndoMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val undoResp = resource.undoMove(gameId)
@@ -142,7 +162,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("redoMove restores")
def testRedoMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
resource.undoMove(gameId)
@@ -154,7 +174,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("drawAction offer")
def testDrawActionOffer(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "offer")
assertEquals(200, resp.getStatus)
@@ -162,7 +182,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("drawAction accept")
def testDrawActionAccept(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "accept")
@@ -172,11 +192,9 @@ class GameResourceIntegrationTest:
@DisplayName("importFen creates game")
def testImportFen(): Unit =
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val req = ImportFenRequestDto(fen, None, None)
val req = ImportFenRequestDto(fen, None, None, None)
val resp = resource.importFen(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
assertEquals(fen, dto.state.fen)
@Test
@DisplayName("importPgn creates game")
@@ -190,7 +208,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("exportFen returns FEN")
def testExportFen(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.exportFen(gameId)
assertEquals(200, resp.getStatus)
@@ -199,10 +217,9 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("exportPgn returns PGN")
def testExportPgn(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val resp = resource.exportPgn(gameId)
assertEquals(200, resp.getStatus)
assertTrue(resp.getEntity.asInstanceOf[String].contains("1."))
// scalafix:on