@@ -48,7 +48,11 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:bot"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:rule"))
|
||||
implementation(project(":modules:io"))
|
||||
implementation(project(":modules:official-bots"))
|
||||
implementation(project(":modules:security"))
|
||||
|
||||
|
||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
@@ -56,6 +60,7 @@ dependencies {
|
||||
implementation("io.quarkus:quarkus-hibernate-orm")
|
||||
implementation("io.quarkus:quarkus-rest-client-jackson")
|
||||
implementation("io.quarkus:quarkus-rest-client")
|
||||
implementation("io.quarkus:quarkus-grpc")
|
||||
implementation("io.quarkus:quarkus-rest-jackson")
|
||||
implementation("io.quarkus:quarkus-config-yaml")
|
||||
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
|
||||
@@ -63,9 +68,10 @@ dependencies {
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
implementation("io.quarkus:quarkus-websockets-next")
|
||||
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
|
||||
implementation("io.quarkus:quarkus-redis-client")
|
||||
|
||||
testImplementation(project(":modules:io"))
|
||||
testImplementation(project(":modules:rule"))
|
||||
@@ -119,3 +125,25 @@ tasks.jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
||||
if (name == "compileScoverageScala") {
|
||||
source = source.asFileTree.matching {
|
||||
exclude("**/grpc/*.scala")
|
||||
exclude("**/coordinator/*.scala")
|
||||
exclude("**/registry/RedisGameRegistry.scala")
|
||||
exclude("**/service/InstanceHeartbeatService.scala")
|
||||
exclude("**/resource/GameDtoMapper.scala")
|
||||
exclude("**/resource/GameResource.scala")
|
||||
exclude("**/redis/GameRedis*.scala")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("compileScoverageJava").configure {
|
||||
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||
}
|
||||
|
||||
tasks.compileScala {
|
||||
dependsOn(tasks.named("compileJava"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
+17
@@ -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()
|
||||
|
||||
+8
-32
@@ -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 =
|
||||
|
||||
+57
-40
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user