From 3706ece9369eb9449cf6d75416434ac1842b2a0a Mon Sep 17 00:00:00 2001 From: Janis Date: Fri, 24 Apr 2026 11:39:50 +0200 Subject: [PATCH] feat(game): enhance game engine with resign functionality and optimize move generation --- .gitignore | 3 + .../src/main/resources/keys/public.pem | 9 + .../api/dto/CreateGameRequestDto.scala | 2 +- .../core/src/main/resources/keys/public.pem | 9 + .../de/nowchess/chess/engine/GameEngine.scala | 4 +- .../io/service/resource/IoResourceTest.scala | 2 +- .../de/nowchess/rules/sets/DefaultRules.scala | 784 ++++++++++-------- 7 files changed, 463 insertions(+), 350 deletions(-) create mode 100644 modules/account/src/main/resources/keys/public.pem create mode 100644 modules/core/src/main/resources/keys/public.pem diff --git a/.gitignore b/.gitignore index 50e5622..8113cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ bin/ graphify-out/ .graphify_*.json +### Keys ### +**/keys/private.pem + ### Mac OS ### .DS_Store /jacoco-reporter/.venv/ diff --git a/modules/account/src/main/resources/keys/public.pem b/modules/account/src/main/resources/keys/public.pem new file mode 100644 index 0000000..6b6b842 --- /dev/null +++ b/modules/account/src/main/resources/keys/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ +g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J +Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf +634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH +YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr +7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn +WQIDAQAB +-----END PUBLIC KEY----- diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala index 496166d..f8fc019 100644 --- a/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala +++ b/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala @@ -6,5 +6,5 @@ final case class CreateGameRequestDto( white: Option[PlayerInfoDto], black: Option[PlayerInfoDto], timeControl: Option[TimeControlDto], - mode: Option[GameMode], + mode: Option[GameMode] = None, ) diff --git a/modules/core/src/main/resources/keys/public.pem b/modules/core/src/main/resources/keys/public.pem new file mode 100644 index 0000000..6b6b842 --- /dev/null +++ b/modules/core/src/main/resources/keys/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ +g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J +Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf +634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH +YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr +7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn +WQIDAQAB +-----END PUBLIC KEY----- diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 1b452d5..9a11758 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -142,6 +142,8 @@ 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)) @@ -150,7 +152,7 @@ class GameEngine( pendingDrawOffer = None stopClock() invoker.clear() - notifyObservers(ResignEvent(currentContext, color.opposite)) + notifyObservers(ResignEvent(currentContext, color)) } /** Offer a draw. */ diff --git a/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala b/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala index 2091794..643bf54 100644 --- a/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.scala.DefaultScalaModule import de.nowchess.api.board.Square import de.nowchess.api.game.GameContext -import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer} +import de.nowchess.json.{SquareKeyDeserializer, SquareKeySerializer} import io.quarkus.test.junit.QuarkusTest import io.restassured.RestAssured import io.restassured.http.ContentType diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index ed3bde2..99cf5ff 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -5,58 +5,413 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.rules.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. */ 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,299 +426,14 @@ 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 - val board = context.board + val board = context.board val newBoard = move.moveType match case MoveType.CastleKingside => applyCastle(board, color, kingside = true) @@ -389,6 +459,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 +468,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 +487,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 +501,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,25 +509,53 @@ 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 private def insufficientMaterial(board: Board): Boolean = val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King } nonKings match - case Nil => true + 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