diff --git a/modules/bot/python/datasets/ds_v1/metadata.json b/modules/bot/python/datasets/ds_v1/metadata.json index e641224..d29ac91 100644 --- a/modules/bot/python/datasets/ds_v1/metadata.json +++ b/modules/bot/python/datasets/ds_v1/metadata.json @@ -1,7 +1,7 @@ { "version": 1, "created": "2026-04-13T19:58:38.629943", - "total_positions": 2522562, + "total_positions": 3022562, "stockfish_depth": 12, "sources": [ { @@ -46,6 +46,21 @@ } ], "actual_count": 599993 + }, + { + "type": "merged_sources", + "count": 500000, + "sources": [ + { + "type": "lichess", + "count": 500000, + "params": { + "min_depth": 20, + "max_positions": 500000 + } + } + ], + "actual_count": 500000 } ] } \ No newline at end of file diff --git a/modules/bot/python/weights/nnue_weights_best_snapshot.pt b/modules/bot/python/weights/nnue_weights_best_snapshot.pt index 4e3ff54..0f96b90 100644 Binary files a/modules/bot/python/weights/nnue_weights_best_snapshot.pt and b/modules/bot/python/weights/nnue_weights_best_snapshot.pt differ diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala index a517053..c38a83b 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala @@ -1,21 +1,28 @@ package de.nowchess.chess.controller import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.move.PromotionPiece object Parser: - /** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected - * format. + /** Parses UCI move notation: "e2e4" (4 chars) or "e7e8q" (5 chars with promotion piece suffix). + * The promotion suffix is q=Queen, r=Rook, b=Bishop, n=Knight. Returns None for invalid input. */ - def parseMove(input: String): Option[(Square, Square)] = + def parseMove(input: String): Option[(Square, Square, Option[PromotionPiece])] = val trimmed = input.trim.toLowerCase - Option - .when(trimmed.length == 4)(trimmed) - .flatMap: s => + trimmed.length match + case 4 => for - from <- parseSquare(s.substring(0, 2)) - to <- parseSquare(s.substring(2, 4)) - yield (from, to) + from <- parseSquare(trimmed.substring(0, 2)) + to <- parseSquare(trimmed.substring(2, 4)) + yield (from, to, None) + case 5 => + for + from <- parseSquare(trimmed.substring(0, 2)) + to <- parseSquare(trimmed.substring(2, 4)) + promo <- parsePromotion(trimmed(4)) + yield (from, to, Some(promo)) + case _ => None private def parseSquare(s: String): Option[Square] = Option @@ -26,3 +33,10 @@ object Parser: Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)( Square(File.values(fileIdx), Rank.values(rankIdx)), ) + + private def parsePromotion(c: Char): Option[PromotionPiece] = c match + case 'q' => Some(PromotionPiece.Queen) + case 'r' => Some(PromotionPiece.Rook) + case 'b' => Some(PromotionPiece.Bishop) + case 'n' => Some(PromotionPiece.Knight) + case _ => None 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 9e9e2c7..4294fd7 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 @@ -19,13 +19,8 @@ class GameEngine( val ruleSet: RuleSet = DefaultRules, val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human), ) extends Observable: - // Ensure that initialBoard is set correctly for threefold repetition detection - private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then - initialContext.copy(initialBoard = initialContext.board) - else - initialContext @SuppressWarnings(Array("DisableSyntax.var")) - private var currentContext: GameContext = contextWithInitialBoard + private var currentContext: GameContext = initialContext private val invoker = new CommandInvoker() /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */ @@ -72,15 +67,11 @@ class GameEngine( currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule))) invoker.clear() notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule)) - else if ruleSet.isThreefoldRepetition(currentContext) then - currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition))) - invoker.clear() - notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition)) else notifyObservers( InvalidMoveEvent( currentContext, - "Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.", + "Draw cannot be claimed: the 50-move rule has not been triggered.", ), ) @@ -167,7 +158,7 @@ class GameEngine( invoker.clear() if ctx.moves.isEmpty then - currentContext = ctx.copy(initialBoard = ctx.board) + currentContext = ctx Right(()) else replayMoves(ctx.moves, savedContext) @@ -195,12 +186,7 @@ class GameEngine( /** Load an arbitrary board position, clearing all history and undo/redo state. */ def loadPosition(newContext: GameContext): Unit = synchronized { - val contextWithInitialBoard = if newContext.moves.isEmpty then - newContext.copy(initialBoard = newContext.board) - else - newContext - currentContext = contextWithInitialBoard - pendingPromotion = None + currentContext = newContext invoker.clear() notifyObservers(BoardResetEvent(currentContext)) } @@ -257,7 +243,6 @@ class GameEngine( else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext)) if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) - if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext)) // Request bot move if it's the opponent bot's turn if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index 446b677..117742e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -1,6 +1,6 @@ package de.nowchess.chess.observer -import de.nowchess.api.board.{Color, Square} +import de.nowchess.api.board.Color import de.nowchess.api.game.{DrawReason, GameContext} /** Base trait for all game state events. Events are immutable snapshots of game state changes. @@ -39,13 +39,6 @@ case class InvalidMoveEvent( reason: String, ) extends GameEvent -/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */ -case class PromotionRequiredEvent( - context: GameContext, - from: Square, - to: Square, -) extends GameEvent - /** Fired when the board is reset. */ case class BoardResetEvent( context: GameContext, diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala index a602a4c..e6b3aa3 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala @@ -1,25 +1,26 @@ package de.nowchess.chess.controller import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.move.PromotionPiece import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers class ParserTest extends AnyFunSuite with Matchers: test("parseMove parses valid 'e2e4'"): - Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) test("parseMove is case-insensitive"): - Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) test("parseMove trims leading and trailing whitespace"): - Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) test("parseMove handles corner squares a1h8"): - Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8))) + Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8), None)) test("parseMove handles corner squares h8a1"): - Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1))) + Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1), None)) test("parseMove returns None for empty string"): Parser.parseMove("") shouldBe None @@ -27,8 +28,8 @@ class ParserTest extends AnyFunSuite with Matchers: test("parseMove returns None for input shorter than 4 chars"): Parser.parseMove("e2e") shouldBe None - test("parseMove returns None for input longer than 4 chars"): - Parser.parseMove("e2e44") shouldBe None + test("parseMove returns None for input longer than 5 chars"): + Parser.parseMove("e2e4qq") shouldBe None test("parseMove returns None when from-file is out of range"): Parser.parseMove("z2e4") shouldBe None @@ -41,3 +42,31 @@ class ParserTest extends AnyFunSuite with Matchers: test("parseMove returns None when to-rank is out of range"): Parser.parseMove("e2e9") shouldBe None + + test("parseMove parses queen promotion 'e7e8q'"): + Parser.parseMove("e7e8q") shouldBe Some( + (Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Queen)), + ) + + test("parseMove parses rook promotion 'a7a8r'"): + Parser.parseMove("a7a8r") shouldBe Some( + (Square(File.A, Rank.R7), Square(File.A, Rank.R8), Some(PromotionPiece.Rook)), + ) + + test("parseMove parses bishop promotion 'e7e8b'"): + Parser.parseMove("e7e8b") shouldBe Some( + (Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Bishop)), + ) + + test("parseMove parses knight promotion 'e7e8n'"): + Parser.parseMove("e7e8n") shouldBe Some( + (Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Knight)), + ) + + test("parseMove returns None for 5-char input with invalid promotion char"): + Parser.parseMove("e7e8x") shouldBe None + + test("parseMove parses black promotion 'e2e1q'"): + Parser.parseMove("e2e1q") shouldBe Some( + (Square(File.E, Rank.R2), Square(File.E, Rank.R1), Some(PromotionPiece.Queen)), + ) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala index 268b9c8..9e491e8 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala @@ -1,8 +1,9 @@ package de.nowchess.chess.engine import scala.collection.mutable -import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent} +import de.nowchess.api.board.Color +import de.nowchess.api.game.DrawReason +import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -29,10 +30,6 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: case other => fail(s"Expected CheckmateEvent, but got $other") - // Board should be reset after checkmate - engine.board shouldBe Board.initial - engine.turn shouldBe Color.White - test("GameEngine handles check detection"): val engine = new GameEngine() val observer = new EndingMockObserver() @@ -87,13 +84,9 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: observer.events.clear() engine.processUserInput(moves.last) - val stalemateEvents = observer.events.collect { case e: StalemateEvent => e } + val stalemateEvents = observer.events.collect { case DrawEvent(_, DrawReason.Stalemate) => true } stalemateEvents.size shouldBe 1 - // Board should be reset after stalemate - engine.board shouldBe Board.initial - engine.turn shouldBe Color.White - private class EndingMockObserver extends Observer: val events = mutable.ListBuffer[GameEvent]() diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index febf116..193170d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -115,7 +115,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.loadGame(importer, "ignored") shouldBe Right(()) engine.context.moves.lastOption shouldBe Some(promotionMove) - test("loadGame replay restores previous context when promotion cannot be completed"): + test("loadGame replay restores previous context when move has no legal candidates"): val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen)) val noLegalMoves = new RuleSet: def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty @@ -140,7 +140,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: val result = engine.loadGame(importer, "ignored") result.isLeft shouldBe true - result.left.toOption.get should include("Promotion required") + result.left.toOption.get should include("Illegal move") engine.context shouldBe saved test("loadGame replay executes non-promotion moves through default replay branch"): @@ -156,7 +156,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)) val trailingMove = Move(sq("e2"), sq("e4")) - engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1") + engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Illegal move.") engine.context shouldBe saved test("normalMoveNotation handles missing source piece"): diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala index 5e14c11..e1c1877 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala @@ -2,7 +2,6 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Rank, Square} import de.nowchess.api.game.GameContext -import de.nowchess.api.move.PromotionPiece import de.nowchess.io.fen.FenParser import de.nowchess.chess.observer.* import org.scalatest.funsuite.AnyFunSuite @@ -90,7 +89,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // ── Bishop underpromotion notation (line 230) ────────────────────── test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"): - val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get + // White rook on h2 keeps material sufficient (K+R+B vs K) after bishop promotion + val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6R/7K").get val ctx = GameContext.initial .withBoard(board) .withTurn(Color.White) @@ -99,8 +99,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: val engine = new GameEngine(ctx) val events = captureEvents(engine) - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Bishop) + engine.processUserInput("e7e8b") events.clear() engine.undo() @@ -111,8 +110,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // ── King normal move notation (line 246) ─────────────────────────── test("undo after king move emits MoveUndoneEvent with K notation"): - // White king on e1, no castling rights, black king far away - val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get + // Black pawn on h7 prevents K-vs-K insufficient-material draw; white king on e1, no castling rights + val board = FenParser.parseBoard("k7/7p/8/8/8/8/8/4K3").get val ctx = GameContext.initial .withBoard(board) .withTurn(Color.White) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 0742af6..ffb64aa 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} -import de.nowchess.api.game.GameContext +import de.nowchess.api.game.{DrawReason, GameContext} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.io.fen.FenParser import de.nowchess.chess.observer.* @@ -22,7 +22,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: private def engineWith(board: Board, turn: Color = Color.White): GameEngine = new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn)) - test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") { + test("processUserInput without promotion suffix fires InvalidMoveEvent when pawn reaches back rank") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val engine = engineWith(promotionBoard) val events = captureEvents(engine) @@ -30,36 +30,18 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.processUserInput("e7e8") events.exists { - case _: PromotionRequiredEvent => true - case _ => false + case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required") + case _ => false } should be(true) - events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7)) } - test("isPendingPromotion is true after PromotionRequired input") { - val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = engineWith(promotionBoard) - captureEvents(engine) - - engine.processUserInput("e7e8") - - engine.isPendingPromotion should be(true) - } - - test("isPendingPromotion is false before any promotion input") { - val engine = new GameEngine() - engine.isPendingPromotion should be(false) - } - - test("completePromotion fires MoveExecutedEvent with promoted piece") { + test("processUserInput with queen promotion fires MoveExecutedEvent and places queen") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val engine = engineWith(promotionBoard) val events = captureEvents(engine) - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e7e8q") - engine.isPendingPromotion should be(false) engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen))) engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None) engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) @@ -69,37 +51,42 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } should be(true) } - test("completePromotion with rook underpromotion") { + test("processUserInput with rook underpromotion places rook") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val engine = engineWith(promotionBoard) captureEvents(engine) - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Rook) + engine.processUserInput("e7e8r") engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook))) } - test("completePromotion with no pending promotion fires InvalidMoveEvent") { - val engine = new GameEngine() - val events = captureEvents(engine) + test("processUserInput with bishop underpromotion places bishop") { + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val engine = engineWith(promotionBoard) + captureEvents(engine) - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e7e8b") - events.exists { - case _: InvalidMoveEvent => true - case _ => false - } should be(true) - engine.isPendingPromotion should be(false) + engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Bishop))) } - test("completePromotion fires CheckDetectedEvent when promotion gives check") { + test("processUserInput with knight underpromotion places knight") { + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val engine = engineWith(promotionBoard) + captureEvents(engine) + + engine.processUserInput("e7e8n") + + engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Knight))) + } + + test("processUserInput e7e8q fires CheckDetectedEvent when promotion gives check") { val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get val engine = engineWith(promotionBoard) val events = captureEvents(engine) - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e7e8q") events.exists { case _: CheckDetectedEvent => true @@ -107,15 +94,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } should be(true) } - test("completePromotion results in Moved when promotion doesn't give check") { + test("processUserInput e7e8q does not fire CheckDetectedEvent when promotion doesn't give check") { val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get val engine = engineWith(board) val events = captureEvents(engine) - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e7e8q") - engine.isPendingPromotion should be(false) engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen))) events.filter { case _: MoveExecutedEvent => true @@ -127,45 +112,39 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } should be(false) } - test("completePromotion results in Checkmate when promotion delivers checkmate") { + test("processUserInput h7h8q fires CheckmateEvent when promotion delivers checkmate") { val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get val engine = engineWith(board) val events = captureEvents(engine) - engine.processUserInput("h7h8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("h7h8q") - engine.isPendingPromotion should be(false) events.exists { case _: CheckmateEvent => true case _ => false } should be(true) } - test("completePromotion results in Stalemate when promotion creates stalemate") { + test("processUserInput b7b8n fires DrawEvent with Stalemate when promotion creates stalemate") { val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get val engine = engineWith(board) val events = captureEvents(engine) - engine.processUserInput("b7b8") - engine.completePromotion(PromotionPiece.Knight) + engine.processUserInput("b7b8n") - engine.isPendingPromotion should be(false) events.exists { - case _: StalemateEvent => true - case _ => false + case DrawEvent(_, DrawReason.Stalemate) => true + case _ => false } should be(true) } - test("completePromotion with black pawn promotion results in Moved") { + test("processUserInput e2e1q with black pawn promotes to queen") { val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get val engine = engineWith(board, Color.Black) val events = captureEvents(engine) - engine.processUserInput("e2e1") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e2e1q") - engine.isPendingPromotion should be(false) engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen))) events.filter { case _: MoveExecutedEvent => true @@ -177,11 +156,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } should be(false) } - test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") { - // Custom RuleSet: delegates all methods to StandardRules except legalMoves, - // which strips Promotion move types and returns Normal moves instead. - // This makes completePromotion unable to find Move(from, to, Promotion(Queen)), - // triggering the "Error completing promotion." branch. + test("processUserInput fires InvalidMoveEvent when promotion piece has no matching legal move") { + // Custom RuleSet: strips Promotion move types and returns Normal moves instead, + // so Move(e7, e8, Promotion(Queen)) is not in legal moves — triggers error branch. val delegatingRuleSet: RuleSet = new RuleSet: def candidateMoves(context: GameContext)(square: Square): List[Move] = DefaultRules.candidateMoves(context)(square) @@ -213,16 +190,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: val engine = new GameEngine(initialCtx, delegatingRuleSet) val events = captureEvents(engine) - // isPromotionMove will fire because pawn is on rank 7 heading to rank 8, - // and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion - engine.processUserInput("e7e8") - engine.isPendingPromotion should be(true) + // legalMoves returns Normal candidates (non-empty) but no Promotion(Queen) move + engine.processUserInput("e7e8q") - // completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves, - // but only Normal moves exist → fires InvalidMoveEvent - engine.completePromotion(PromotionPiece.Queen) - - engine.isPendingPromotion should be(false) events.exists { case _: InvalidMoveEvent => true case _ => false diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala index d9eb458..dcc06a6 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala @@ -1,7 +1,6 @@ package de.nowchess.chess.engine import de.nowchess.api.board.Color -import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.observer.* import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -89,7 +88,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: // ── Pawn promotion ───────────────────────────────────────────── - test("pawn reaching back rank requires promotion"): + test("pawn reaching back rank without promotion suffix fires InvalidMoveEvent"): val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -99,74 +98,61 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: engine.processUserInput("e7e8") - observer.hasEvent[PromotionRequiredEvent] shouldBe true - engine.isPendingPromotion shouldBe true + observer.hasEvent[InvalidMoveEvent] shouldBe true - test("completePromotion to Queen executes move"): + test("e7e8q promotes to Queen"): val engine = EngineTestHelpers.makeEngine() EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1") - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e7e8q") - engine.isPendingPromotion shouldBe false engine.turn shouldBe Color.Black - test("completePromotion to Rook executes move"): + test("e7e8r promotes to Rook"): val engine = EngineTestHelpers.makeEngine() EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1") - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Rook) + engine.processUserInput("e7e8r") - engine.isPendingPromotion shouldBe false engine.turn shouldBe Color.Black - test("completePromotion to Bishop executes move"): + test("e7e8b promotes to Bishop"): val engine = EngineTestHelpers.makeEngine() EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1") - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Bishop) + engine.processUserInput("e7e8b") - engine.isPendingPromotion shouldBe false engine.turn shouldBe Color.Black - test("completePromotion to Knight executes move"): + test("e7e8n promotes to Knight"): val engine = EngineTestHelpers.makeEngine() EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1") - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Knight) + engine.processUserInput("e7e8n") - engine.isPendingPromotion shouldBe false engine.turn shouldBe Color.Black - test("promotion to Queen with discovered check emits CheckDetectedEvent"): + test("promotion with discovered check emits CheckDetectedEvent"): val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) - // FEN: white pawn e7, black king e6, white king e1 EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/4K3 w - - 0 1") observer.clear() - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("e7e8q") observer.hasEvent[CheckDetectedEvent] shouldBe true - test("promotion to Queen with checkmate emits CheckmateEvent"): + test("promotion with checkmate emits CheckmateEvent"): val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) - // FEN: known promotion-mate pattern EngineTestHelpers.loadFen(engine, "k7/7P/1K6/8/8/8/8/8 w - - 0 1") observer.clear() - engine.processUserInput("h7h8") - engine.completePromotion(PromotionPiece.Queen) + engine.processUserInput("h7h8q") observer.hasEvent[CheckmateEvent] shouldBe true @@ -177,8 +163,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: // White rook on h2 keeps material sufficient (K+B+R vs K) after bishop promotion EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1") - engine.processUserInput("e7e8") - engine.completePromotion(PromotionPiece.Bishop) + engine.processUserInput("e7e8b") observer.clear() engine.undo() @@ -187,16 +172,12 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: evt.isDefined shouldBe true evt.get.pgnNotation shouldBe "e8=B" - test("black pawn promotion executes"): + test("black pawn e2e1q promotes to queen"): val engine = EngineTestHelpers.makeEngine() EngineTestHelpers.loadFen(engine, "8/8/8/8/8/4k3/4p3/8 b - - 0 1") - engine.processUserInput("e2e1") + engine.processUserInput("e2e1q") - engine.isPendingPromotion shouldBe true - engine.completePromotion(PromotionPiece.Queen) - - engine.isPendingPromotion shouldBe false engine.turn shouldBe Color.White // ── Promotion capturing ──────────────────────────────────────── @@ -205,6 +186,6 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: val engine = EngineTestHelpers.makeEngine() EngineTestHelpers.loadFen(engine, "3n4/4P3/4k3/8/8/8/8/4K3 w - - 0 1") - engine.processUserInput("e7d8") + engine.processUserInput("e7d8q") - engine.isPendingPromotion shouldBe true + engine.turn shouldBe Color.Black diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala index b122343..8699235 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala @@ -33,7 +33,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers: case _: CheckmateEvent => checkmateDetected.set(true) gameEnded.set(true) - case _: StalemateEvent => + case _: DrawEvent => gameEnded.set(true) case _ => () 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 1e0523e..866b0a6 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 @@ -455,13 +455,21 @@ object DefaultRules extends RuleSet: // ── Insufficient material ────────────────────────────────────────── + private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2 + private def insufficientMaterial(board: Board): Boolean = - val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King) - pieces match - case Nil => true - case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true - case List(p1, p2) + val nonKings = board.pieces.toList.filter(_._2.pieceType != PieceType.King) + nonKings match + case Nil => true + case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true + case List((sq1, p1), (sq2, p2)) if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop - && p1.color != p2.color => + && p1.color != p2.color + && squareColor(sq1) == squareColor(sq2) => + true + case bishops + if bishops.forall(_._2.pieceType == PieceType.Bishop) + && bishops.map(_._2.color).distinct.size == 1 + && bishops.map(e => squareColor(e._1)).distinct.size == 1 => true case _ => false diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala index 19ed48f..179d0bc 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala @@ -11,7 +11,7 @@ import scalafx.scene.shape.Rectangle import scalafx.scene.text.{Font, Text} import scalafx.stage.Stage import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} -import de.nowchess.api.move.PromotionPiece +import de.nowchess.api.move.MoveType import de.nowchess.chess.command.{MoveCommand, MoveResult} import de.nowchess.chess.engine.GameEngine import de.nowchess.io.fen.{FenExporter, FenParser} @@ -178,36 +178,37 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B square private def handleSquareClick(rank: Int, file: Int): Unit = - if !engine.isPendingPromotion then - val clickedSquare = Square(File.values(file), Rank.values(rank)) + val clickedSquare = Square(File.values(file), Rank.values(rank)) - selectedSquare.get() match - case None => - // First click - select piece if it belongs to current player - currentBoard.get().pieceAt(clickedSquare).foreach { piece => - if piece.color == currentTurn.get() then - selectedSquare.set(Some(clickedSquare)) - highlightSquare(rank, file, PieceSprites.SquareColors.Selected) + selectedSquare.get() match + case None => + // First click - select piece if it belongs to current player + currentBoard.get().pieceAt(clickedSquare).foreach { piece => + if piece.color == currentTurn.get() then + selectedSquare.set(Some(clickedSquare)) + highlightSquare(rank, file, PieceSprites.SquareColors.Selected) - val legalDests = engine.ruleSet - .legalMoves(engine.context)(clickedSquare) - .collect { case move if move.from == clickedSquare => move.to } - legalDests.foreach { sq => - highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) - } - } + val legalDests = engine.ruleSet + .legalMoves(engine.context)(clickedSquare) + .collect { case move if move.from == clickedSquare => move.to } + legalDests.foreach { sq => + highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) + } + } - case Some(fromSquare) => - // Second click - attempt move - if clickedSquare == fromSquare then - // Deselect - selectedSquare.set(None) - updateBoard(currentBoard.get(), currentTurn.get()) - else - // Try to move - val moveStr = s"${fromSquare}$clickedSquare" - engine.processUserInput(moveStr) - selectedSquare.set(None) + case Some(fromSquare) => + // Second click - attempt move + if clickedSquare == fromSquare then + // Deselect + selectedSquare.set(None) + updateBoard(currentBoard.get(), currentTurn.get()) + else + val isPromo = engine.ruleSet + .legalMoves(engine.context)(fromSquare) + .exists(m => m.to == clickedSquare && m.moveType.isInstanceOf[MoveType.Promotion]) + if isPromo then showPromotionDialog(fromSquare, clickedSquare) + else engine.processUserInput(s"${fromSquare}$clickedSquare") + selectedSquare.set(None) def updateBoard(board: Board, turn: Color): Unit = currentBoard.set(board) @@ -280,14 +281,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B headerText = "Choose promotion piece" contentText = "Promote to:" } - - val result = dialog.showAndWait() - result match - case Some("Queen") => engine.completePromotion(PromotionPiece.Queen) - case Some("Rook") => engine.completePromotion(PromotionPiece.Rook) - case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop) - case Some("Knight") => engine.completePromotion(PromotionPiece.Knight) - case _ => engine.completePromotion(PromotionPiece.Queen) // Default + val uciSuffix = dialog.showAndWait() match + case Some("Rook") => "r" + case Some("Bishop") => "b" + case Some("Knight") => "n" + case _ => "q" + engine.processUserInput(s"${from}${to}$uciSuffix") private def doFenExport(): Unit = doExport(FenExporter, "FEN") diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala index f89917c..836263d 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala @@ -47,9 +47,6 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer: boardView.updateBoard(e.context.board, e.context.turn) boardView.showMessage("Board has been reset to initial position.") - case e: PromotionRequiredEvent => - boardView.showPromotionDialog(e.from, e.to) - case e: FiftyMoveRuleAvailableEvent => boardView.showMessage("50-move rule is now available — type 'draw' to claim.") diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala index a8463ca..f059250 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala @@ -6,26 +6,24 @@ import de.nowchess.api.board.{Color, Piece, PieceType} /** Utility object for loading chess piece sprites. */ object PieceSprites: - private val spriteCache = scala.collection.mutable.Map[String, Image]() + private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]() /** Load a piece sprite image from resources. Sprites are cached for performance. */ - def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView = - val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" - val image = spriteCache.getOrElseUpdate(key, loadImage(key)) - - new ImageView(image) { - fitWidth = size - fitHeight = size - preserveRatio = true - smooth = true + def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] = + val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" + spriteCache.getOrElseUpdate(key, loadImage(key)).map { image => + new ImageView(image) { + fitWidth = size + fitHeight = size + preserveRatio = true + smooth = true + } } - private def loadImage(key: String): Image = + private def loadImage(key: String): Option[Image] = val path = s"/sprites/pieces/$key.png" - Option(getClass.getResourceAsStream(path)) match - case Some(stream) => new Image(stream) - case None => sys.error(s"Could not load sprite: $path") + Option(getClass.getResourceAsStream(path)).map(new Image(_)) /** Get square colors for the board using theme. */ object SquareColors: diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index 63cd302..d985be9 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -1,9 +1,7 @@ package de.nowchess.ui.terminal -import java.util.concurrent.atomic.AtomicBoolean import scala.io.StdIn import de.nowchess.api.move.PromotionPiece -import de.nowchess.api.game.DrawReason import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.observer.* import de.nowchess.ui.utils.Renderer @@ -90,7 +88,6 @@ class TerminalUI(engine: GameEngine) extends Observer: print(Renderer.render(engine.board)) printPrompt(engine.turn) - // Game loop while running.get() do val input = Option(StdIn.readLine()).getOrElse("quit").trim synchronized {