feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
+23
View File
@@ -22,8 +22,15 @@ scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
sourceSets {
main {
java.srcDir("build/classes/java/quarkus-generated-sources/grpc")
}
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
dependsOn("quarkusGenerateCode")
}
val quarkusPlatformGroupId: String by project
@@ -44,6 +51,8 @@ dependencies {
}
implementation(project(":modules:api"))
implementation(project(":modules:json"))
implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
@@ -52,6 +61,7 @@ dependencies {
implementation("io.quarkus:quarkus-rest-client")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-grpc")
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-health")
@@ -106,3 +116,16 @@ tasks.reportScoverage {
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.withType(ScalaCompile::class).configureEach {
if (name == "compileScoverageScala") {
source = source.asFileTree.matching {
exclude("**/grpc/*.scala")
}
}
}
tasks.named("compileScoverageJava").configure {
dependsOn(tasks.named("quarkusGenerateCode"))
dependsOn(tasks.named("compileQuarkusGeneratedSourcesJava"))
}
@@ -0,0 +1,87 @@
syntax = "proto3";
option java_package = "de.nowchess.rules.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,38 @@
syntax = "proto3";
option java_package = "de.nowchess.rules.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);
}
@@ -0,0 +1,27 @@
{
"reflection": [
{ "type": "scala.Tuple1[]" },
{ "type": "scala.Tuple2[]" },
{ "type": "scala.Tuple3[]" },
{ "type": "scala.Tuple4[]" },
{ "type": "scala.Tuple5[]" },
{ "type": "scala.Tuple6[]" },
{ "type": "scala.Tuple7[]" },
{ "type": "scala.Tuple8[]" },
{ "type": "scala.Tuple9[]" },
{ "type": "scala.Tuple10[]" },
{ "type": "scala.Tuple11[]" },
{ "type": "scala.Tuple12[]" },
{ "type": "scala.Tuple13[]" },
{ "type": "scala.Tuple14[]" },
{ "type": "scala.Tuple15[]" },
{ "type": "scala.Tuple16[]" },
{ "type": "scala.Tuple17[]" },
{ "type": "scala.Tuple18[]" },
{ "type": "scala.Tuple19[]" },
{ "type": "scala.Tuple20[]" },
{ "type": "scala.Tuple21[]" },
{ "type": "scala.Tuple22[]" },
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
]
}
@@ -1,5 +1,12 @@
quarkus:
http:
port: 8082
grpc:
server:
use-separate-server: false
application:
name: rule-service
nowchess:
internal:
secret: 123abc
@@ -2,11 +2,8 @@ package de.nowchess.rules.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.rules.json.*
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())
@@ -0,0 +1,160 @@
package de.nowchess.rules.grpc
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.rules.proto.*
import scala.jdk.CollectionConverters.*
object ProtoMapper
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,93 @@
package de.nowchess.rules.grpc
import de.nowchess.api.board.Square
import de.nowchess.rules.proto.*
import de.nowchess.rules.sets.DefaultRules
import io.grpc.stub.StreamObserver
import io.grpc.{Status, StatusRuntimeException}
import io.quarkus.grpc.GrpcService
// scalafix:off DisableSyntax.throw
@GrpcService
class RuleGrpcService extends RuleServiceGrpc.RuleServiceImplBase:
private def parseSquare(s: String): Square =
Square
.fromAlgebraic(s)
.getOrElse(
throw Status.INVALID_ARGUMENT.withDescription(s"Invalid square: $s").asRuntimeException(),
)
override def candidateMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
val sq = parseSquare(req.getSquare)
val moves = DefaultRules.candidateMoves(ctx)(sq)
resp.onNext(
ProtoMoveList
.newBuilder()
.addAllMoves(toJavaMoveList(moves))
.build(),
)
resp.onCompleted()
override def legalMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
val sq = parseSquare(req.getSquare)
val moves = DefaultRules.legalMoves(ctx)(sq)
respond(resp, ProtoMoveList.newBuilder().addAllMoves(toJavaMoveList(moves)).build())
override def allLegalMoves(req: ProtoGameContext, resp: StreamObserver[ProtoMoveList]): Unit =
val moves = DefaultRules.allLegalMoves(ProtoMapper.fromProtoGameContext(req))
respond(resp, ProtoMoveList.newBuilder().addAllMoves(toJavaMoveList(moves)).build())
override def isCheck(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
respond(resp, boolResult(DefaultRules.isCheck(ProtoMapper.fromProtoGameContext(req))))
override def isCheckmate(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
respond(resp, boolResult(DefaultRules.isCheckmate(ProtoMapper.fromProtoGameContext(req))))
override def isStalemate(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
respond(resp, boolResult(DefaultRules.isStalemate(ProtoMapper.fromProtoGameContext(req))))
override def isInsufficientMaterial(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
respond(resp, boolResult(DefaultRules.isInsufficientMaterial(ProtoMapper.fromProtoGameContext(req))))
override def isFiftyMoveRule(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
respond(resp, boolResult(DefaultRules.isFiftyMoveRule(ProtoMapper.fromProtoGameContext(req))))
override def isThreefoldRepetition(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
respond(resp, boolResult(DefaultRules.isThreefoldRepetition(ProtoMapper.fromProtoGameContext(req))))
override def applyMove(req: ProtoMoveRequest, resp: StreamObserver[ProtoGameContext]): Unit =
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
val move = ProtoMapper
.fromProtoMove(req.getMove)
.getOrElse(
throw Status.INVALID_ARGUMENT.withDescription("Invalid move").asRuntimeException(),
)
respond(resp, ProtoMapper.toProtoGameContext(DefaultRules.applyMove(ctx)(move)))
override def postMoveStatus(req: ProtoGameContext, resp: StreamObserver[ProtoPostMoveStatus]): Unit =
val status = DefaultRules.postMoveStatus(ProtoMapper.fromProtoGameContext(req))
respond(
resp,
ProtoPostMoveStatus
.newBuilder()
.setIsCheckmate(status.isCheckmate)
.setIsStalemate(status.isStalemate)
.setIsInsufficientMaterial(status.isInsufficientMaterial)
.setIsCheck(status.isCheck)
.setIsThreefoldRepetition(status.isThreefoldRepetition)
.build(),
)
private def boolResult(v: Boolean): ProtoBoolResult = ProtoBoolResult.newBuilder().setValue(v).build()
private def respond[T](obs: StreamObserver[T], value: T): Unit =
obs.onNext(value)
obs.onCompleted()
private def toJavaMoveList(moves: List[de.nowchess.api.move.Move]): java.util.List[ProtoMove] =
import scala.jdk.CollectionConverters.*
moves.map(ProtoMapper.toProtoMove).asJava
// scalafix:on DisableSyntax.throw
@@ -1,19 +0,0 @@
package de.nowchess.rules.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.rules.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.rules.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,8 +0,0 @@
package de.nowchess.rules.json
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.rules.json
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)
@@ -1,9 +0,0 @@
package de.nowchess.rules.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)
@@ -4,6 +4,8 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.dto.*
import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.security.InternalOnly
import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.*
@@ -11,6 +13,7 @@ import jakarta.ws.rs.core.MediaType
@Path("/api/rules")
@ApplicationScoped
@InternalOnly
class RuleSetResource:
private val rules = DefaultRules
@@ -88,3 +91,10 @@ class RuleSetResource:
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: ContextMoveRequest): GameContext =
rules.applyMove(req.context)(req.move)
@POST
@Path("/post-move-status")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def postMoveStatus(ctx: GameContext): PostMoveStatus =
rules.postMoveStatus(ctx)
@@ -3,60 +3,463 @@ package de.nowchess.rules.sets
import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.rules.RuleSet
import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
import scala.annotation.tailrec
/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
/** Standard chess rules — optimized hot path.
*
* Internal representation: Array[Int](64), indexed by file + rank*8. Piece encoding: 0=empty, +1..+6=white
* (P/N/B/R/Q/K), -1..-6=black. Move generation uses pre-computed ray/jump tables and an integer-encoded move word to
* avoid heap allocation in tight loops. Check detection uses make/unmake on the mutable array instead of copying the
* immutable Board map.
*/
// scalafix:off DisableSyntax.var
// scalafix:off DisableSyntax.return
object DefaultRules extends RuleSet:
/** Represents a position for threefold repetition (board state + turn + castling + en passant). */
private case class Position(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
)
// ─── Piece constants ──────────────────────────────────────────────────────
private val PAWN = 1; private val KNIGHT = 2; private val BISHOP = 3
private val ROOK = 4; private val QUEEN = 5; private val KING = 6
// ── Direction vectors ──────────────────────────────────────────────
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
private val KnightJumps: List[(Int, Int)] =
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
private inline def idx(f: Int, r: Int): Int = f + (r << 3)
private inline def fileOf(sq: Int): Int = sq & 7
private inline def rankOf(sq: Int): Int = sq >> 3
private inline def isEmpty(p: Int): Boolean = p == 0
private inline def isWhitePiece(p: Int): Boolean = p > 0
private inline def pieceType(p: Int): Int = if p > 0 then p else -p
// ── Pawn configuration helpers ─────────────────────────────────────
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
private def encodePiece(c: Color, pt: PieceType): Int =
val raw = pt match
case PieceType.Pawn => PAWN; case PieceType.Knight => KNIGHT
case PieceType.Bishop => BISHOP; case PieceType.Rook => ROOK
case PieceType.Queen => QUEEN; case PieceType.King => KING
if c == Color.White then raw else -raw
// ── Public API ─────────────────────────────────────────────────────
// ── Pre-computed tables ──────────────────────────────────────────────────
private val KNIGHT_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq =>
val (f, r) = (fileOf(sq), rankOf(sq))
Array((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2)).collect {
case (df, dr) if f + df >= 0 && f + df < 8 && r + dr >= 0 && r + dr < 8 => idx(f + df, r + dr)
}
}
private val KING_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq =>
val (f, r) = (fileOf(sq), rankOf(sq))
Array((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)).collect {
case (df, dr) if f + df >= 0 && f + df < 8 && r + dr >= 0 && r + dr < 8 => idx(f + df, r + dr)
}
}
// Directions 0-3: rook (N,S,E,W); 4-7: bishop (NE,NW,SE,SW)
private val DIR_VECS: Array[(Int, Int)] =
Array((0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1))
// RAY_TABLES(sq)(d) = squares along direction d from sq, nearest first
private val RAY_TABLES: Array[Array[Array[Int]]] = Array.tabulate(64, 8) { (sq, d) =>
val (df, dr) = DIR_VECS(d)
val (f, r) = (fileOf(sq), rankOf(sq))
val buf = new scala.collection.mutable.ArrayBuffer[Int](7)
var nf = f + df; var nr = r + dr
while nf >= 0 && nf < 8 && nr >= 0 && nr < 8 do
buf += idx(nf, nr); nf += df; nr += dr
buf.toArray
}
// PAWN_ATTACK_SOURCES(colorIdx)(target) = squares from which a pawn of that color attacks target.
// White pawn (fwd=+1) at (f±1, r-1) attacks (f, r) → sources are at rank r-1.
// Black pawn (fwd=-1) at (f±1, r+1) attacks (f, r) → sources are at rank r+1.
private val PAWN_ATTACK_SOURCES: Array[Array[Array[Int]]] = Array.tabulate(2) { colorIdx =>
val fwd = if colorIdx == 0 then 1 else -1
Array.tabulate(64) { sq =>
val (f, r) = (fileOf(sq), rankOf(sq))
Array(-1, 1).collect {
case df if f + df >= 0 && f + df < 8 && r - fwd >= 0 && r - fwd < 8 => idx(f + df, r - fwd)
}
}
}
// Pre-computed castling square indices (no runtime string parsing)
private val A1 = idx(0, 0); private val B1 = idx(1, 0); private val C1 = idx(2, 0)
private val D1 = idx(3, 0); private val E1 = idx(4, 0); private val F1 = idx(5, 0)
private val G1 = idx(6, 0); private val H1 = idx(7, 0)
private val A8 = idx(0, 7); private val B8 = idx(1, 7); private val C8 = idx(2, 7)
private val D8 = idx(3, 7); private val E8 = idx(4, 7); private val F8 = idx(5, 7)
private val G8 = idx(6, 7); private val H8 = idx(7, 7)
// Thread-local mutable board and move buffer — zero heap allocation in hot loops
private val tlBoard = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](64))
// 320 slots: theoretical max ~218 chess moves, promotion bursts add 4 per pawn-on-7th
private val tlMoves = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](320))
// ─── Move word encoding ───────────────────────────────────────────────────
// bits 0-5: from square, bits 6-11: to square, bits 12-15: move kind
private val KIND_QUIET = 0; private val KIND_CAPTURE = 1; private val KIND_EP = 2
private val KIND_CASTLEK = 3; private val KIND_CASTLEQ = 4
private val KIND_PROMO_Q = 5; private val KIND_PROMO_R = 6
private val KIND_PROMO_B = 7; private val KIND_PROMO_N = 8
private inline def encMove(from: Int, to: Int, kind: Int): Int = from | (to << 6) | (kind << 12)
private inline def moveFrom(m: Int): Int = m & 63
private inline def moveTo(m: Int): Int = (m >> 6) & 63
private inline def moveKind(m: Int): Int = m >> 12
// ─── Board ↔ Array[Int] ──────────────────────────────────────────────────
private def fillBoard(board: Board, arr: Array[Int]): Unit =
java.util.Arrays.fill(arr, 0)
board.pieces.foreach { (sq, piece) =>
arr(idx(sq.file.ordinal, sq.rank.ordinal)) = encodePiece(piece.color, piece.pieceType)
}
private def toSquare(sq: Int): Square =
Square(File.values(fileOf(sq)), Rank.values(rankOf(sq)))
// ─── Attack detection (reverse lookup from target) ────────────────────────
// Cast rays/jumps FROM the target to find attackers — O(directions × ray_length) vs O(64 × ray_length)
private def isAttackedByColor(arr: Array[Int], target: Int, byWhite: Boolean): Boolean =
val sign = if byWhite then 1 else -1
val colorIdx = if byWhite then 0 else 1
// Pawn
val pawnSrcs = PAWN_ATTACK_SOURCES(colorIdx)(target); var i = 0
while i < pawnSrcs.length do
if arr(pawnSrcs(i)) == sign * PAWN then return true
i += 1
// Knight
val knightSrcs = KNIGHT_TARGETS(target); i = 0
while i < knightSrcs.length do
if arr(knightSrcs(i)) == sign * KNIGHT then return true
i += 1
// King
val kingSrcs = KING_TARGETS(target); i = 0
while i < kingSrcs.length do
if arr(kingSrcs(i)) == sign * KING then return true
i += 1
// Rook/Queen on rook rays (directions 0-3)
val rays = RAY_TABLES(target); i = 0
while i < 4 do
val ray = rays(i); var j = 0
while j < ray.length do
val p = arr(ray(j))
if p != 0 then
if p == sign * ROOK || p == sign * QUEEN then return true
j = ray.length // blocked
j += 1
i += 1
// Bishop/Queen on bishop rays (directions 4-7)
i = 4
while i < 8 do
val ray = rays(i); var j = 0
while j < ray.length do
val p = arr(ray(j))
if p != 0 then
if p == sign * BISHOP || p == sign * QUEEN then return true
j = ray.length // blocked
j += 1
i += 1
false
private def findKing(arr: Array[Int], whiteKing: Boolean): Int =
val king = if whiteKing then KING else -KING; var sq = 0
while sq < 64 do
if arr(sq) == king then return sq
sq += 1
-1
// ─── Make/unmake for check validation ────────────────────────────────────
// Applies move on mutable arr, tests check, undoes — no Map copy.
private def leavesKingInCheck(arr: Array[Int], move: Int, whiteMoved: Boolean): Boolean =
val from = moveFrom(move); val to = moveTo(move); val kind = moveKind(move)
val savedFrom = arr(from); val savedTo = arr(to)
var epSq = -1
var rookFrom = -1; var savedRookPiece = 0; var rookTo = -1
kind match
case KIND_EP =>
epSq = idx(fileOf(to), rankOf(from))
arr(to) = savedFrom; arr(from) = 0; arr(epSq) = 0
case KIND_CASTLEK =>
rookFrom = if whiteMoved then H1 else H8
rookTo = if whiteMoved then F1 else F8
savedRookPiece = arr(rookFrom)
arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0
case KIND_CASTLEQ =>
rookFrom = if whiteMoved then A1 else A8
rookTo = if whiteMoved then D1 else D8
savedRookPiece = arr(rookFrom)
arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0
case k if k >= KIND_PROMO_Q =>
val promoted = k match
case KIND_PROMO_Q => if whiteMoved then QUEEN else -QUEEN
case KIND_PROMO_R => if whiteMoved then ROOK else -ROOK
case KIND_PROMO_B => if whiteMoved then BISHOP else -BISHOP
case _ => if whiteMoved then KNIGHT else -KNIGHT
arr(to) = promoted; arr(from) = 0
case _ =>
arr(to) = savedFrom; arr(from) = 0
val kingSq = findKing(arr, whiteMoved)
val inCheck = kingSq >= 0 && isAttackedByColor(arr, kingSq, !whiteMoved)
// Undo
arr(from) = savedFrom; arr(to) = savedTo
if epSq >= 0 then arr(epSq) = if whiteMoved then -PAWN else PAWN
if rookFrom >= 0 then
arr(rookFrom) = savedRookPiece
arr(rookTo) = 0
inCheck
// ─── Move generation ─────────────────────────────────────────────────────
private def generateAll(arr: Array[Int], isWhite: Boolean, ctx: GameContext, buf: Array[Int]): Int =
var n = 0; var sq = 0
while sq < 64 do
val p = arr(sq)
if !isEmpty(p) && isWhitePiece(p) == isWhite then n = generatePiece(arr, sq, pieceType(p), isWhite, ctx, buf, n)
sq += 1
n
private def generatePiece(
arr: Array[Int],
sq: Int,
pt: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
n: Int,
): Int =
if pt == PAWN then generatePawnMoves(arr, sq, isWhite, ctx, buf, n)
else if pt == KNIGHT then generateJumps(arr, sq, isWhite, KNIGHT_TARGETS(sq), buf, n)
else if pt == BISHOP then generateRays(arr, sq, isWhite, buf, n, rookRays = false)
else if pt == ROOK then generateRays(arr, sq, isWhite, buf, n, rookRays = true)
else if pt == QUEEN then
val n2 = generateRays(arr, sq, isWhite, buf, n, rookRays = true)
generateRays(arr, sq, isWhite, buf, n2, rookRays = false)
else generateKingMoves(arr, sq, isWhite, ctx, buf, n)
private def generateJumps(
arr: Array[Int],
from: Int,
isWhite: Boolean,
targets: Array[Int],
buf: Array[Int],
start: Int,
): Int =
var n = start; var i = 0
while i < targets.length do
val to = targets(i); val tgt = arr(to)
if isEmpty(tgt) then
buf(n) = encMove(from, to, KIND_QUIET); n += 1
else if isWhitePiece(tgt) != isWhite then
buf(n) = encMove(from, to, KIND_CAPTURE); n += 1
i += 1
n
private def generateRays(
arr: Array[Int],
from: Int,
isWhite: Boolean,
buf: Array[Int],
start: Int,
rookRays: Boolean,
): Int =
var n = start
val rays = RAY_TABLES(from)
val d0 = if rookRays then 0 else 4
val d1 = if rookRays then 4 else 8
var d = d0
while d < d1 do
val ray = rays(d); var j = 0
while j < ray.length do
val to = ray(j); val tgt = arr(to)
if isEmpty(tgt) then
buf(n) = encMove(from, to, KIND_QUIET); n += 1
else
if isWhitePiece(tgt) != isWhite then
buf(n) = encMove(from, to, KIND_CAPTURE); n += 1
j = ray.length
j += 1
d += 1
n
private def generateKingMoves(
arr: Array[Int],
from: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
start: Int,
): Int =
val n = generateJumps(arr, from, isWhite, KING_TARGETS(from), buf, start)
generateCastlingMoves(arr, from, isWhite, ctx, buf, n)
private def generateCastlingMoves(
arr: Array[Int],
from: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
start: Int,
): Int =
var n = start
val cr = ctx.castlingRights
if isWhite && from == E1 then
if cr.whiteKingSide && isEmpty(arr(F1)) && isEmpty(arr(G1)) &&
arr(E1) == KING && arr(H1) == ROOK &&
!isAttackedByColor(arr, E1, false) &&
!isAttackedByColor(arr, F1, false) &&
!isAttackedByColor(arr, G1, false)
then
buf(n) = encMove(E1, G1, KIND_CASTLEK); n += 1
if cr.whiteQueenSide && isEmpty(arr(D1)) && isEmpty(arr(C1)) && isEmpty(arr(B1)) &&
arr(E1) == KING && arr(A1) == ROOK &&
!isAttackedByColor(arr, E1, false) &&
!isAttackedByColor(arr, D1, false) &&
!isAttackedByColor(arr, C1, false)
then
buf(n) = encMove(E1, C1, KIND_CASTLEQ); n += 1
else if !isWhite && from == E8 then
if cr.blackKingSide && isEmpty(arr(F8)) && isEmpty(arr(G8)) &&
arr(E8) == -KING && arr(H8) == -ROOK &&
!isAttackedByColor(arr, E8, true) &&
!isAttackedByColor(arr, F8, true) &&
!isAttackedByColor(arr, G8, true)
then
buf(n) = encMove(E8, G8, KIND_CASTLEK); n += 1
if cr.blackQueenSide && isEmpty(arr(D8)) && isEmpty(arr(C8)) && isEmpty(arr(B8)) &&
arr(E8) == -KING && arr(A8) == -ROOK &&
!isAttackedByColor(arr, E8, true) &&
!isAttackedByColor(arr, D8, true) &&
!isAttackedByColor(arr, C8, true)
then
buf(n) = encMove(E8, C8, KIND_CASTLEQ); n += 1
n
private def generatePawnMoves(
arr: Array[Int],
from: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
start: Int,
): Int =
var n = start
val f = fileOf(from); val r = rankOf(from)
val fwd = if isWhite then 1 else -1
val startRank = if isWhite then 1 else 6
val promoRank = if isWhite then 7 else 0
val r1 = r + fwd
if r1 >= 0 && r1 < 8 then
val to1 = idx(f, r1)
if isEmpty(arr(to1)) then
if r1 == promoRank then
buf(n) = encMove(from, to1, KIND_PROMO_Q); n += 1
buf(n) = encMove(from, to1, KIND_PROMO_R); n += 1
buf(n) = encMove(from, to1, KIND_PROMO_B); n += 1
buf(n) = encMove(from, to1, KIND_PROMO_N); n += 1
else
buf(n) = encMove(from, to1, KIND_QUIET); n += 1
if r == startRank then
val to2 = idx(f, r + fwd * 2)
if isEmpty(arr(to2)) then
buf(n) = encMove(from, to2, KIND_QUIET); n += 1
var di = 0
while di < 2 do
val nf = f + (if di == 0 then -1 else 1)
if nf >= 0 && nf < 8 then
val to = idx(nf, r1)
val tgt = arr(to)
if !isEmpty(tgt) && isWhitePiece(tgt) != isWhite then
if r1 == promoRank then
buf(n) = encMove(from, to, KIND_PROMO_Q); n += 1
buf(n) = encMove(from, to, KIND_PROMO_R); n += 1
buf(n) = encMove(from, to, KIND_PROMO_B); n += 1
buf(n) = encMove(from, to, KIND_PROMO_N); n += 1
else
buf(n) = encMove(from, to, KIND_CAPTURE); n += 1
di += 1
ctx.enPassantSquare.foreach { epSq =>
val epI = idx(epSq.file.ordinal, epSq.rank.ordinal)
val epF = fileOf(epI); val epR = rankOf(epI)
if epR == r1 && (epF == f - 1 || epF == f + 1) then
buf(n) = encMove(from, epI, KIND_EP); n += 1
}
n
// ─── Decode integer move word → API Move ─────────────────────────────────
private def decodeMoveToApi(m: Int): Move =
val fromSq = toSquare(moveFrom(m)); val toSq = toSquare(moveTo(m))
moveKind(m) match
case KIND_QUIET => Move(fromSq, toSq)
case KIND_CAPTURE => Move(fromSq, toSq, MoveType.Normal(isCapture = true))
case KIND_EP => Move(fromSq, toSq, MoveType.EnPassant)
case KIND_CASTLEK => Move(fromSq, toSq, MoveType.CastleKingside)
case KIND_CASTLEQ => Move(fromSq, toSq, MoveType.CastleQueenside)
case KIND_PROMO_Q => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Queen))
case KIND_PROMO_R => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Rook))
case KIND_PROMO_B => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Bishop))
case _ => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Knight))
// ─── Public RuleSet API ───────────────────────────────────────────────────
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
if piece.color != context.turn then List.empty[Move]
else
piece.pieceType match
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
case PieceType.Knight => knightCandidates(context, square, piece.color)
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
case PieceType.King => kingCandidates(context, square, piece.color)
}
val arr = new Array[Int](64)
fillBoard(context.board, arr)
val sqI = idx(square.file.ordinal, square.rank.ordinal)
val piece = arr(sqI)
if isEmpty(piece) || isWhitePiece(piece) != (context.turn == Color.White) then return Nil
val buf = new Array[Int](64)
val n = generatePiece(arr, sqI, pieceType(piece), context.turn == Color.White, context, buf, 0)
(0 until n).map(i => decodeMoveToApi(buf(i))).toList
override def legalMoves(context: GameContext)(square: Square): List[Move] =
candidateMoves(context)(square).filter { move =>
!leavesKingInCheck(context, move)
}
val arr = tlBoard.get(); fillBoard(context.board, arr)
val sqI = idx(square.file.ordinal, square.rank.ordinal)
val piece = arr(sqI)
val isWhite = context.turn == Color.White
if isEmpty(piece) || isWhitePiece(piece) != isWhite then return Nil
val buf = tlMoves.get()
val n = generatePiece(arr, sqI, pieceType(piece), isWhite, context, buf, 0)
val result = new scala.collection.mutable.ListBuffer[Move]()
var i = 0
while i < n do
if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i))
i += 1
result.toList
override def allLegalMoves(context: GameContext): List[Move] =
Square.all.flatMap(sq => legalMoves(context)(sq)).toList
val arr = tlBoard.get(); fillBoard(context.board, arr)
val isWhite = context.turn == Color.White
val buf = tlMoves.get()
val n = generateAll(arr, isWhite, context, buf)
val result = new scala.collection.mutable.ListBuffer[Move]()
var i = 0
while i < n do
if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i))
i += 1
result.toList
override def isCheck(context: GameContext): Boolean =
kingSquare(context.board, context.turn)
.fold(false)(sq => isAttackedBy(context.board, sq, context.turn.opposite))
val arr = tlBoard.get(); fillBoard(context.board, arr)
val isWhite = context.turn == Color.White
val kingSq = findKing(arr, isWhite)
kingSq >= 0 && isAttackedByColor(arr, kingSq, !isWhite)
override def isCheckmate(context: GameContext): Boolean =
isCheck(context) && allLegalMoves(context).isEmpty
@@ -71,295 +474,10 @@ object DefaultRules extends RuleSet:
context.halfMoveClock >= 100
override def isThreefoldRepetition(context: GameContext): Boolean =
val currentPosition = Position(
board = context.board,
turn = context.turn,
castlingRights = context.castlingRights,
enPassantSquare = context.enPassantSquare,
)
val currentPosition = Position(context.board, context.turn, context.castlingRights, context.enPassantSquare)
countPositionOccurrences(context, currentPosition) >= 3
private def countPositionOccurrences(context: GameContext, targetPosition: Position): Int =
try
val initialCtx = GameContext(
board = context.initialBoard,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
initialBoard = context.initialBoard,
)
def positionOf(ctx: GameContext): Position =
Position(
board = ctx.board,
turn = ctx.turn,
castlingRights = ctx.castlingRights,
enPassantSquare = ctx.enPassantSquare,
)
val initialCount = if positionOf(initialCtx) == targetPosition then 1 else 0
context.moves
.foldLeft((initialCtx, initialCount)) { case ((tempCtx, count), move) =>
val nextCtx = applyMove(tempCtx)(move)
val nextCount = if positionOf(nextCtx) == targetPosition then count + 1 else count
(nextCtx, nextCount)
}
._2
catch
case _: Exception =>
// If replay fails, conservatively count only the current position (never triggers a draw)
1
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
private def slidingMoves(
context: GameContext,
from: Square,
color: Color,
dirs: List[(Int, Int)],
): List[Move] =
dirs.flatMap(dir => castRay(context.board, from, color, dir))
private def castRay(
board: Board,
from: Square,
color: Color,
dir: (Int, Int),
): List[Move] =
@tailrec
def loop(sq: Square, acc: List[Move]): List[Move] =
sq.offset(dir._1, dir._2) match
case None => acc
case Some(next) =>
board.pieceAt(next) match
case None => loop(next, Move(from, next) :: acc)
case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
case Some(_) => acc
loop(from, Nil).reverse
// ── Knight ─────────────────────────────────────────────────────────
private def knightCandidates(
context: GameContext,
from: Square,
color: Color,
): List[Move] =
KnightJumps.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match
case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to))
}
}
// ── King ───────────────────────────────────────────────────────────
private def kingCandidates(
context: GameContext,
from: Square,
color: Color,
): List[Move] =
val steps = QueenDirs.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match
case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to))
}
}
steps ++ castlingCandidates(context, from, color)
// ── Castling ───────────────────────────────────────────────────────
private case class CastlingMove(
kingFromAlg: String,
kingToAlg: String,
middleAlg: String,
rookFromAlg: String,
moveType: MoveType,
)
private def castlingCandidates(
context: GameContext,
from: Square,
color: Color,
): List[Move] =
color match
case Color.White => whiteCastles(context, from)
case Color.Black => blackCastles(context, from)
private def whiteCastles(context: GameContext, from: Square): List[Move] =
val expected = Square.fromAlgebraic("e1").getOrElse(from)
if from != expected then List.empty
else
val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(
context,
moves,
context.castlingRights.whiteKingSide,
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.whiteQueenSide,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
)
moves.toList
private def blackCastles(context: GameContext, from: Square): List[Move] =
val expected = Square.fromAlgebraic("e8").getOrElse(from)
if from != expected then List.empty
else
val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(
context,
moves,
context.castlingRights.blackKingSide,
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.blackQueenSide,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
)
moves.toList
private def queensideBSquare(kingToAlg: String): List[String] =
kingToAlg match
case "c1" => List("b1")
case "c8" => List("b8")
case _ => List.empty
private def addCastleMove(
context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean,
castlingMove: CastlingMove,
): Unit =
if castlingRight then
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
.flatMap(Square.fromAlgebraic)
if squaresEmpty(context.board, clearSqs) then
for
kf <- Square.fromAlgebraic(castlingMove.kingFromAlg)
km <- Square.fromAlgebraic(castlingMove.middleAlg)
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
do
val color = context.turn
val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
val squaresSafe =
!isAttackedBy(context.board, kf, color.opposite) &&
!isAttackedBy(context.board, km, color.opposite) &&
!isAttackedBy(context.board, kt, color.opposite)
if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
squares.forall(sq => board.pieceAt(sq).isEmpty)
// ── Pawn ───────────────────────────────────────────────────────────
private def pawnCandidates(
context: GameContext,
from: Square,
color: Color,
): List[Move] =
val fwd = pawnForward(color)
val startRank = pawnStartRank(color)
val promoRank = pawnPromoRank(color)
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
val double = Option
.when(from.rank.ordinal == startRank) {
from.offset(0, fwd).flatMap { mid =>
Option
.when(context.board.pieceAt(mid).isEmpty) {
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
}
.flatten
}
}
.flatten
val diagonalCaptures = List(-1, 1).flatMap { df =>
from.offset(df, fwd).flatMap { to =>
context.board.pieceAt(to).filter(_.color != color).map(_ => to)
}
}
val epCaptures: List[Move] = context.enPassantSquare.toList.flatMap { epSq =>
List(-1, 1).flatMap { df =>
from.offset(df, fwd).filter(_ == epSq).map { to =>
Move(from, epSq, MoveType.EnPassant)
}
}
}
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
if dest.rank.ordinal == promoRank then
List(
PromotionPiece.Queen,
PromotionPiece.Rook,
PromotionPiece.Bishop,
PromotionPiece.Knight,
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
val stepSquares = single.toList ++ double.toList
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
stepMoves ++ captureMoves ++ epCaptures
// ── Check detection ────────────────────────────────────────────────
private def kingSquare(board: Board, color: Color): Option[Square] =
Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
Square.all.exists { sq =>
board.pieceAt(sq).fold(false) { p =>
p.color == attacker && squareAttacks(board, sq, p, target)
}
}
private def squareAttacks(board: Board, from: Square, piece: Piece, target: Square): Boolean =
val fwd = pawnForward(piece.color)
piece.pieceType match
case PieceType.Pawn =>
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
case PieceType.Knight =>
KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
case PieceType.King =>
QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
dirs.exists { dir =>
@tailrec
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
case None => false
case Some(next) if next == target => true
case Some(next) if board.pieceAt(next).isEmpty => loop(next)
case Some(_) => false
loop(from)
}
private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
val nextBoard = context.board.applyMove(move)
val nextContext = context.withBoard(nextBoard)
isCheck(nextContext)
// ── Move application ───────────────────────────────────────────────
// ─── applyMove (immutable GameContext update — acceptable for real moves) ─
override def applyMove(context: GameContext)(move: Move): GameContext =
val color = context.turn
@@ -389,6 +507,8 @@ object DefaultRules extends RuleSet:
.withHalfMoveClock(newClock)
.withMove(move)
// ─── Move application helpers ─────────────────────────────────────────────
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) =
@@ -396,15 +516,10 @@ object DefaultRules extends RuleSet:
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
board
.removed(kingFrom)
.removed(rookFrom)
.updated(kingTo, king)
.updated(rookTo, rook)
board.removed(kingFrom).removed(rookFrom).updated(kingTo, king).updated(rookTo, rook)
private def applyEnPassant(board: Board, move: Move): Board =
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
val capturedSquare = Square(move.to.file, capturedRank)
val capturedSquare = Square(move.to.file, move.from.rank)
board.applyMove(move).removed(capturedSquare)
private def applyPromotion(board: Board, move: Move, color: Color, pp: PromotionPiece): Board =
@@ -420,14 +535,10 @@ object DefaultRules extends RuleSet:
val isKingMove = piece.exists(_.pieceType == PieceType.King)
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
// Helper to check if a square is a rook's starting square
val whiteKingsideRook = Square(File.H, Rank.R1)
val whiteQueensideRook = Square(File.A, Rank.R1)
val blackKingsideRook = Square(File.H, Rank.R8)
val blackQueensideRook = Square(File.A, Rank.R8)
val whiteKingsideRook = Square(File.H, Rank.R1); val whiteQueensideRook = Square(File.A, Rank.R1)
val blackKingsideRook = Square(File.H, Rank.R8); val blackQueensideRook = Square(File.A, Rank.R8)
val afterKingMove = if isKingMove then rights.revokeColor(color) else rights
val afterRookMove =
if !isRookMove then afterKingMove
else
@@ -438,7 +549,6 @@ object DefaultRules extends RuleSet:
case `blackQueensideRook` => afterKingMove.revokeQueenSide(Color.Black)
case _ => afterKingMove
// Also revoke if a rook is captured
move.to match
case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White)
case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White)
@@ -447,16 +557,14 @@ object DefaultRules extends RuleSet:
case _ => afterRookMove
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
val piece = board.pieceAt(move.from)
val isDoublePawnPush = piece.exists(_.pieceType == PieceType.Pawn) &&
val isDoublePawnPush = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) &&
math.abs(move.to.rank.ordinal - move.from.rank.ordinal) == 2
if isDoublePawnPush then
// EP square is the square the pawn passed through
val epRankOrd = (move.from.rank.ordinal + move.to.rank.ordinal) / 2
Some(Square(move.from.file, Rank.values(epRankOrd)))
else None
// ── Insufficient material ──────────────────────────────────────────
// ── Insufficient material ────────────────────────────────────────────────
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
@@ -465,7 +573,50 @@ object DefaultRules extends RuleSet:
nonKings match
case Nil => true
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
// All non-king pieces are bishops: draw only if they all share the same square color
case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1
case _ => false
// ─── Threefold repetition ─────────────────────────────────────────────────
private case class Position(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
)
private def countPositionOccurrences(context: GameContext, target: Position): Int =
try
val initialCtx = GameContext(
board = context.initialBoard,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
initialBoard = context.initialBoard,
)
def positionOf(ctx: GameContext): Position =
Position(ctx.board, ctx.turn, ctx.castlingRights, ctx.enPassantSquare)
val initialCount = if positionOf(initialCtx) == target then 1 else 0
context.moves
.foldLeft((initialCtx, initialCount)) { case ((tempCtx, count), move) =>
val nextCtx = applyMove(tempCtx)(move)
val nextCount = if positionOf(nextCtx) == target then count + 1 else count
(nextCtx, nextCount)
}
._2
catch case _: Exception => 1
override def postMoveStatus(context: GameContext): PostMoveStatus =
PostMoveStatus(
isCheckmate = isCheckmate(context),
isStalemate = isStalemate(context),
isInsufficientMaterial = isInsufficientMaterial(context),
isCheck = isCheck(context),
isThreefoldRepetition = isThreefoldRepetition(context),
)
@@ -0,0 +1,5 @@
nowchess:
internal:
secret: test-secret
auth:
enabled: false
@@ -11,7 +11,7 @@ import org.scalatest.matchers.should.Matchers
class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
private def contextFromFen(fen: String): GameContext =
FenParser.parseFen(fen).fold(err => fail(err), identity)
FenParser.parseFen(fen).fold(err => fail(err.message), identity)
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
@@ -1,102 +1,31 @@
package de.nowchess.rules.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.rules.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.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())
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""""
// ── SquareKeySerializer ───────────────────────────────────────────
test("customize registers SquareSerializer"):
mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
test("SquareKeySerializer writes square as map field name"):
mapper.writeValueAsString(Map(e4 -> "piece")) shouldBe """{"e4":"piece"}"""
test("customize registers SquareDeserializer"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
// ── SquareKeyDeserializer ─────────────────────────────────────────
// scalafix:off DisableSyntax.null
test("SquareKeyDeserializer returns square for valid key"):
new SquareKeyDeserializer().deserializeKey("e4", null) shouldBe e4
test("SquareKeyDeserializer returns null for invalid key"):
new SquareKeyDeserializer().deserializeKey("z9", null) shouldBe null
// scalafix:on DisableSyntax.null
// ── SquareSerializer/Deserializer ─────────────────────────────────
test("SquareSerializer writes square as string"):
mapper.writeValueAsString(e4) shouldBe """"e4""""
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])