From 5df5a1875f2146d57ac1b007bfa2f531ddfb445a Mon Sep 17 00:00:00 2001 From: Janis Date: Mon, 13 Apr 2026 17:37:24 +0200 Subject: [PATCH] feat: Improved how NNUE Evalutes --- .codesight/CODESIGHT.md | 38 ++- .codesight/graph.md | 10 +- .codesight/libs.md | 28 +- .../scala/de/nowchess/bot/bots/NNUEBot.scala | 37 ++- .../bot/bots/classic/EvaluationClassic.scala | 8 +- .../de/nowchess/bot/bots/nnue/NNUE.scala | 3 +- .../nowchess/bot/logic/AlphaBetaSearch.scala | 48 +-- .../bot/logic/TranspositionTable.scala | 3 +- .../de/nowchess/bot/util/PolyglotBook.scala | 18 +- .../de/nowchess/bot/util/ZobristHash.scala | 11 +- .../de/nowchess/chess/engine/GameEngine.scala | 38 ++- .../engine/GameEngineGameEndingTest.scala | 25 +- .../engine/GameEngineIntegrationTest.scala | 10 +- .../chess/engine/GameEngineNotationTest.scala | 22 +- .../engine/GameEnginePromotionTest.scala | 55 +++- .../chess/engine/GameEngineWithBotTest.scala | 43 +-- .../scala/de/nowchess/io/fen/FenParser.scala | 2 +- .../de/nowchess/io/GameFileServiceSuite.scala | 2 +- .../de/nowchess/rules/sets/DefaultRules.scala | 280 ++++++++++-------- .../de/nowchess/rule/DefaultRulesTest.scala | 13 +- modules/ui/build.gradle.kts | 5 + .../src/main/scala/de/nowchess/ui/Main.scala | 5 +- .../de/nowchess/ui/gui/PieceSprites.scala | 26 +- 23 files changed, 438 insertions(+), 292 deletions(-) diff --git a/.codesight/CODESIGHT.md b/.codesight/CODESIGHT.md index 53fbe7f..5bde930 100644 --- a/.codesight/CODESIGHT.md +++ b/.codesight/CODESIGHT.md @@ -94,18 +94,34 @@ - function getBot - function listBots - `modules/bot/src/main/scala/de/nowchess/bot/Config.scala` — class Config -- `modules/bot/src/main/scala/de/nowchess/bot/ai/Weights.scala` - - class Weights +- `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` + - class Evaluation - class CHECKMATE_SCORE - class DRAW_SCORE - function evaluate -- `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` — class EvaluationClassic, function evaluate + - function initAccumulator + - function copyAccumulator + - _...2 more_ +- `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` + - class EvaluationClassic + - function evaluate + - function countRay - `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala` — class EvaluationNNUE, function evaluate - `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala` - class NNUE + - function initAccumulator + - function pushAccumulator + - function copyAccumulator + - function evaluateAtPly - function evaluate - - function benchmark -- `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala` — function bestMove, function bestMoveWithTime + - _...1 more_ +- `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala` + - function bestMove + - function bestMoveWithTime + - function loop + - function loop + - function loop + - function loop - `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` - class MoveOrdering - class OrderingContext @@ -118,7 +134,7 @@ - function probe - function store - function clear -- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — function probe +- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — function probe, function select - `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala` — class PolyglotHash, function hash - `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala` - class ZobristHash @@ -254,11 +270,11 @@ - `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **59** files - `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **39** files +- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **39** files - `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **36** files -- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **36** files - `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **22** files - `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **21** files -- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **20** files +- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **21** files - `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **17** files - `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **10** files - `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files @@ -275,13 +291,13 @@ ## Import Map (who imports what) -- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Weights.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala` +54 more +- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala` +54 more - `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/move/Move.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` +34 more +- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` +34 more - `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` +31 more -- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala` +31 more - `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +17 more - `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala` +16 more -- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala`, `modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala` +15 more +- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +16 more - `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +12 more - `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +5 more - `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` ← `modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala` +5 more diff --git a/.codesight/graph.md b/.codesight/graph.md index 698486c..46d2a58 100644 --- a/.codesight/graph.md +++ b/.codesight/graph.md @@ -4,11 +4,11 @@ - `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **59** files - `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **39** files +- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **39** files - `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **36** files -- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **36** files - `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **22** files - `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **21** files -- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **20** files +- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **21** files - `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **17** files - `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **10** files - `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files @@ -25,13 +25,13 @@ ## Import Map (who imports what) -- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Weights.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala` +54 more +- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala` +54 more - `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/move/Move.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` +34 more +- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` +34 more - `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` +31 more -- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala` +31 more - `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +17 more - `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala` +16 more -- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala`, `modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala` +15 more +- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +16 more - `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +12 more - `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +5 more - `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` ← `modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala` +5 more diff --git a/.codesight/libs.md b/.codesight/libs.md index 136b433..ea90781 100644 --- a/.codesight/libs.md +++ b/.codesight/libs.md @@ -85,18 +85,34 @@ - function getBot - function listBots - `modules/bot/src/main/scala/de/nowchess/bot/Config.scala` — class Config -- `modules/bot/src/main/scala/de/nowchess/bot/ai/Weights.scala` - - class Weights +- `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` + - class Evaluation - class CHECKMATE_SCORE - class DRAW_SCORE - function evaluate -- `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` — class EvaluationClassic, function evaluate + - function initAccumulator + - function copyAccumulator + - _...2 more_ +- `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` + - class EvaluationClassic + - function evaluate + - function countRay - `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala` — class EvaluationNNUE, function evaluate - `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala` - class NNUE + - function initAccumulator + - function pushAccumulator + - function copyAccumulator + - function evaluateAtPly - function evaluate - - function benchmark -- `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala` — function bestMove, function bestMoveWithTime + - _...1 more_ +- `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala` + - function bestMove + - function bestMoveWithTime + - function loop + - function loop + - function loop + - function loop - `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` - class MoveOrdering - class OrderingContext @@ -109,7 +125,7 @@ - function probe - function store - function clear -- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — function probe +- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — function probe, function select - `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala` — class PolyglotHash, function hash - `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala` - class ZobristHash diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala index 04b4f7d..1906a7a 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala @@ -4,7 +4,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.AlphaBetaSearch -import de.nowchess.bot.util.PolyglotBook +import de.nowchess.bot.util.{PolyglotBook, ZobristHash} import de.nowchess.bot.{Bot, BotDifficulty} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules @@ -22,11 +22,36 @@ final class NNUEBot( override def nextMove(context: GameContext): Option[Move] = book .flatMap(_.probe(context)) - .orElse(search.bestMoveWithTime(context, allocateTime(context))) + .orElse { + val moves = rules.allLegalMoves(context) + if moves.isEmpty then None + else + val scored = batchEvaluateRoot(context, moves) + val bestMove = scored.maxBy(_._2)._1 + search.bestMoveWithTime(context, allocateTime(scored)).orElse(Some(bestMove)) + } - /** Allocate more time for complex or critical positions. */ - private def allocateTime(context: GameContext): Long = - val moveCount = rules.allLegalMoves(context).length + /** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score + * from the root player's perspective. + */ + private def batchEvaluateRoot(context: GameContext, moves: List[Move]): List[(Move, Int)] = + EvaluationNNUE.initAccumulator(context) + val rootHash = ZobristHash.hash(context) + moves.map { move => + val child = rules.applyMove(context)(move) + val childHash = ZobristHash.nextHash(context, rootHash, move, child) + EvaluationNNUE.pushAccumulator(1, move, context, child) + val score = -EvaluationNNUE.evaluateAccumulator(1, child, childHash) + (move, score) + } + + /** Allocate more time for complex positions; less when one move clearly dominates. */ + private def allocateTime(scored: List[(Move, Int)]): Long = + val moveCount = scored.length if moveCount > 30 then 1500L else if moveCount < 5 then 500L - else 1000L + else + val scores = scored.map(_._2) + val best = scores.max + val second = scores.filter(_ < best).maxOption.getOrElse(best) + if best - second > 200 then 600L else 1000L diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala index de40dff..3d846a2 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala @@ -249,7 +249,7 @@ object EvaluationClassic extends Evaluation: @scala.annotation.tailrec def countRay(current: Option[Square], acc: Int): Int = current match - case None => acc + case None => acc case Some(target) => board.pieceAt(target) match case Some(piece) if piece.color == color => acc @@ -287,10 +287,10 @@ object EvaluationClassic extends Evaluation: val friendlyHasPair = friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) && - friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1) + friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1) val enemyHasPair = enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) && - enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1) + enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 1) val baseMg = (if friendlyHasPair then bishopPairMg else 0) - (if enemyHasPair then bishopPairMg else 0) val baseEg = (if friendlyHasPair then bishopPairEg else 0) - (if enemyHasPair then bishopPairEg else 0) @@ -312,7 +312,7 @@ object EvaluationClassic extends Evaluation: val kingCentralBonus = friendlyKing.fold(0)((kSq, _) => (8 - kingCentralizationDistance(kSq)) * 15) - - enemyKing.fold(0)((kSq, _) => (8 - kingCentralizationDistance(kSq)) * 15) + enemyKing.fold(0)((kSq, _) => (8 - kingCentralizationDistance(kSq)) * 15) val friendlyMaterial = materialCount(context, context.turn) val enemyMaterial = materialCount(context, context.turn.opposite) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala index d833d3e..c4c17ca 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala @@ -15,8 +15,7 @@ class NNUE: // l1WeightsT(featureIdx * 1536 + outputIdx) = l1Weights(outputIdx * 768 + featureIdx) private val l1WeightsT: Array[Float] = val t = new Array[Float](768 * 1536) - for j <- 0 until 768; i <- 0 until 1536 do - t(j * 1536 + i) = l1Weights(i * 768 + j) + for j <- 0 until 768; i <- 0 until 1536 do t(j * 1536 + i) = l1Weights(i * 768 + j) t private def loadWeights(): ( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala index a703532..216c313 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala @@ -42,11 +42,14 @@ final class AlphaBetaSearch( timeLimitMs.set(Long.MaxValue / 4) nodeCount.set(0) val rootHash = ZobristHash.hash(context) - (1 to maxDepth).foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) => - val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) - val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash) - (move.orElse(bestSoFar), score) - }._1 + (1 to maxDepth) + .foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) => + val (alpha, beta) = + if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) + val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash) + (move.orElse(bestSoFar), score) + } + ._1 /** Return the best move for the side to move within a time budget (ms). Uses iterative deepening, stopping when time * runs out. @@ -64,7 +67,8 @@ final class AlphaBetaSearch( def loop(bestSoFar: Option[Move], prevScore: Int, depth: Int): Option[Move] = if isOutOfTime then bestSoFar else - val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) + val (alpha, beta) = + if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash) loop(move.orElse(bestSoFar), score, depth + 1) @@ -85,15 +89,13 @@ final class AlphaBetaSearch( @scala.annotation.tailrec def loop(currentAlpha: Int, currentBeta: Int, window: Int, attempt: Int): (Int, Option[Move]) = - if attempt >= 3 || attempt >= depth then - search(context, depth, 0, -INF, INF, rootHash, repetitions) + if attempt >= 3 || attempt >= depth then search(context, depth, 0, -INF, INF, rootHash, repetitions) else val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, rootHash, repetitions) if score > currentAlpha && score < currentBeta then (score, move) else if score <= currentAlpha then loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) - else - loop(currentAlpha, score + window, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) + else loop(currentAlpha, score + window, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) loop(alpha, beta, initialWindow, 0) @@ -136,10 +138,8 @@ final class AlphaBetaSearch( repetitions: Map[Long, Int], ): (Int, Option[Move]) = val count = nodeCount.incrementAndGet() - if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then - (weights.evaluateAccumulator(ply, context, hash), None) - else if repetitions.getOrElse(hash, 0) >= 3 then - (weights.DRAW_SCORE, None) + if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, hash), None) + else if repetitions.getOrElse(hash, 0) >= 3 then (weights.DRAW_SCORE, None) else val ttCutoff = tt.probe(hash).filter(_.depth >= depth).flatMap { entry => entry.flag match @@ -155,10 +155,8 @@ final class AlphaBetaSearch( val legalMoves = rules.allLegalMoves(context) if legalMoves.isEmpty then (if rules.isCheckmate(context) then -(weights.CHECKMATE_SCORE - ply) else weights.DRAW_SCORE, None) - else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then - (weights.DRAW_SCORE, None) - else if depth == 0 then - (quiescence(context, ply, alpha, beta, hash), None) + else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then (weights.DRAW_SCORE, None) + else if depth == 0 then (quiescence(context, ply, alpha, beta, hash), None) else val nullResult = Option .when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) { @@ -192,7 +190,7 @@ final class AlphaBetaSearch( ): (Option[Move], Int, Boolean) = if idx >= ordered.length then (bestMove, bestScore, false) else - val move = ordered(idx) + val move = ordered(idx) val isQuiet = !isCapture(context, move) && move.moveType != MoveType.CastleKingside && move.moveType != MoveType.CastleQueenside @@ -233,11 +231,14 @@ final class AlphaBetaSearch( if newA >= beta then if isQuiet then - ordering.addHistory(move.from.rank.ordinal * 8 + move.from.file.ordinal, move.to.rank.ordinal * 8 + move.to.file.ordinal, depth * depth) + ordering.addHistory( + move.from.rank.ordinal * 8 + move.from.file.ordinal, + move.to.rank.ordinal * 8 + move.to.file.ordinal, + depth * depth, + ) ordering.addKillerMove(ply, move) (newBestMove, newBestScore, true) - else - loop(idx + 1, newBestMove, newBestScore, newA, moveNumber + 1) + else loop(idx + 1, newBestMove, newBestScore, newA, moveNumber + 1) val (bestMove, bestScore, cutoff) = loop(0, None, -INF, alpha, 0) val flag = @@ -262,8 +263,7 @@ final class AlphaBetaSearch( else val a0 = if inCheck then alpha else math.max(alpha, standPat) - if ply >= MAX_QUIESCENCE_PLY then - if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat + if ply >= MAX_QUIESCENCE_PLY then if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat else val allMoves = rules.allLegalMoves(context) val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m)) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala index b7b4254..27e7a13 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala @@ -34,5 +34,4 @@ final class TranspositionTable(val sizePow2: Int = 20): } def clear(): Unit = - for i <- 0 until size do - locks(i).synchronized { table(i) = None } + for i <- 0 until size do locks(i).synchronized { table(i) = None } diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala b/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala index 33c2ed9..b6fb3e4 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala @@ -82,15 +82,15 @@ final class PolyglotBook(path: String): if isKingMove(context, from) && isRookSquare(to, context) then Some(decodeCastling(from, to)) else - val moveTypeOpt: Option[MoveType] = if promotionBits > 0 then - promotionBits match - case 1 => Some(MoveType.Promotion(PromotionPiece.Knight)) - case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop)) - case 3 => Some(MoveType.Promotion(PromotionPiece.Rook)) - case 4 => Some(MoveType.Promotion(PromotionPiece.Queen)) - case _ => None - else - Some(MoveType.Normal(context.board.pieces.contains(to))) + val moveTypeOpt: Option[MoveType] = + if promotionBits > 0 then + promotionBits match + case 1 => Some(MoveType.Promotion(PromotionPiece.Knight)) + case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop)) + case 3 => Some(MoveType.Promotion(PromotionPiece.Rook)) + case 4 => Some(MoveType.Promotion(PromotionPiece.Queen)) + case _ => None + else Some(MoveType.Normal(context.board.pieces.contains(to))) moveTypeOpt.map(moveType => Move(from, to, moveType)) diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala b/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala index 57bcfa6..c0f2949 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala @@ -74,7 +74,7 @@ object ZobristHash: context.board.pieceAt(move.from).fold(h0) { pawn => val capturedSquare = Square(move.to.file, move.from.rank) val h1 = h0 ^ pieceKey(move.from, pawn) - val h2 = context.board.pieceAt(capturedSquare).fold(h1)(captured => h1 ^ pieceKey(capturedSquare, captured)) + val h2 = context.board.pieceAt(capturedSquare).fold(h1)(captured => h1 ^ pieceKey(capturedSquare, captured)) h2 ^ pieceKey(move.to, pawn) } @@ -106,9 +106,12 @@ object ZobristHash: case PromotionPiece.Queen => PieceType.Queen private def toggleCastling(h0: Long, before: GameContext, after: GameContext): Long = - val h1 = if before.castlingRights.whiteKingSide != after.castlingRights.whiteKingSide then h0 ^ castlingRands(0) else h0 - val h2 = if before.castlingRights.whiteQueenSide != after.castlingRights.whiteQueenSide then h1 ^ castlingRands(1) else h1 - val h3 = if before.castlingRights.blackKingSide != after.castlingRights.blackKingSide then h2 ^ castlingRands(2) else h2 + val h1 = + if before.castlingRights.whiteKingSide != after.castlingRights.whiteKingSide then h0 ^ castlingRands(0) else h0 + val h2 = + if before.castlingRights.whiteQueenSide != after.castlingRights.whiteQueenSide then h1 ^ castlingRands(1) else h1 + val h3 = + if before.castlingRights.blackKingSide != after.castlingRights.blackKingSide then h2 ^ castlingRands(2) else h2 if before.castlingRights.blackQueenSide != after.castlingRights.blackQueenSide then h3 ^ castlingRands(3) else h3 private def toggleEnPassant(h0: Long, before: GameContext, after: GameContext): Long = 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 8c2061e..d2a3730 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 @@ -10,7 +10,7 @@ import de.nowchess.io.{GameContextExport, GameContextImport} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import de.nowchess.bot.Bot -import scala.concurrent.{Future, ExecutionContext} +import scala.concurrent.{ExecutionContext, Future} /** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the * injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents. @@ -34,16 +34,18 @@ class GameEngine( private var pendingPromotion: Option[PendingPromotion] = None /** Optional opponent bot and the color it plays. */ + @SuppressWarnings(Array("DisableSyntax.var")) private var opponentBot: Option[Bot] = None - private var opponentColor: Option[Color] = None + @SuppressWarnings(Array("DisableSyntax.var")) + private var opponentColor: Option[Color] = None private implicit val ec: ExecutionContext = ExecutionContext.global /** True if a pawn promotion move is pending and needs a piece choice. */ def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined) - /** Set an opponent bot to play against. - * The bot will play as the given color and auto-play moves after the opponent moves. - */ + /** Set an opponent bot to play against. The bot will play as the given color and auto-play moves after the opponent + * moves. + */ def setOpponentBot(bot: Bot, color: Color): Unit = synchronized { opponentBot = Some(bot) opponentColor = Some(color) @@ -275,9 +277,8 @@ class GameEngine( // Request bot move if it's the opponent bot's turn if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then - () // Game is over, don't request bot move - else - requestBotMoveIfNeeded() + () // Game is over, don't request bot move + else requestBotMoveIfNeeded() private def translateMoveToNotation(move: Move, boardBefore: Board): String = move.moveType match @@ -328,24 +329,23 @@ class GameEngine( case _ => context.board.pieceAt(move.to) - /** Request a move from the opponent bot if it's their turn. - * Spawns an async task to avoid blocking the engine. - */ + /** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine. + */ private def requestBotMoveIfNeeded(): Unit = (opponentBot, opponentColor) match case (Some(bot), Some(color)) if currentContext.turn == color => Future { bot.nextMove(currentContext) match case Some(move) => applyBotMove(move, color) - case None => handleBotNoMove() + case None => handleBotNoMove() } - case _ => () // No bot or not bot's turn + case _ => () // No bot or not bot's turn private def applyBotMove(move: Move, color: Color): Unit = synchronized { if currentContext.turn == color then val from = move.from - val to = move.to + val to = move.to currentContext.board.pieceAt(from) match case Some(piece) if piece.color == color => val legal = ruleSet.legalMoves(currentContext)(from) @@ -353,13 +353,12 @@ class GameEngine( case Some(legalMove) => val isPromotion = move.moveType match case MoveType.Promotion(_) => true - case _ => false + case _ => false if isPromotion then move.moveType match case MoveType.Promotion(pp) => completePromotion(pp) - case _ => () - else - executeMove(legalMove) + case _ => () + else executeMove(legalMove) case None => notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal")) case _ => @@ -371,8 +370,7 @@ class GameEngine( if ruleSet.isCheckmate(currentContext) then val winner = currentContext.turn.opposite notifyObservers(CheckmateEvent(currentContext, winner)) - else if ruleSet.isStalemate(currentContext) then - notifyObservers(StalemateEvent(currentContext)) + else if ruleSet.isStalemate(currentContext) then notifyObservers(StalemateEvent(currentContext)) } private def performUndo(): Unit = 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 13a5bc3..268b9c8 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,9 +1,8 @@ package de.nowchess.chess.engine import scala.collection.mutable -import de.nowchess.api.board.Color -import de.nowchess.api.game.DrawReason -import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer} +import de.nowchess.api.board.{Board, Color} +import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -24,10 +23,15 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: engine.processUserInput("d8h4") // Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent) - observer.events.last shouldBe a[CheckmateEvent] + observer.events.last match + case event: CheckmateEvent => + event.winner shouldBe Color.Black + case other => + fail(s"Expected CheckmateEvent, but got $other") - val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get - event.winner shouldBe Color.Black + // 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() @@ -83,9 +87,12 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: observer.events.clear() engine.processUserInput(moves.last) - val drawEvents = observer.events.collect { case e: DrawEvent => e } - drawEvents.size shouldBe 1 - drawEvents.head.reason shouldBe DrawReason.Stalemate + val stalemateEvents = observer.events.collect { case e: StalemateEvent => e } + 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 185a6ad..febf116 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 @@ -38,7 +38,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.processUserInput("undo") engine.processUserInput("redo") - events.count { case _: InvalidMoveEvent => true; case _ => false } should be >= 3 + events.count { + case _: InvalidMoveEvent => true + case _ => false + } should be >= 3 test("processUserInput emits Illegal move for syntactically valid but illegal target"): val engine = new GameEngine() @@ -69,7 +72,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.context shouldBe target engine.commandHistory shouldBe empty - events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true + events.lastOption.exists { + case _: de.nowchess.chess.observer.BoardResetEvent => true + case _ => false + } shouldBe true test("redo event includes captured piece description when replaying a capture"): val engine = new GameEngine() 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 8ace7dc..5e14c11 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 @@ -43,7 +43,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // White castles queenside: e1c1 engine.processUserInput("e1c1") - events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) + events.exists { + case _: MoveExecutedEvent => true + case _ => false + } should be(true) events.clear() engine.undo() @@ -68,7 +71,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // White pawn on e5 captures en passant to d6 engine.processUserInput("e5d6") - events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) + events.exists { + case _: MoveExecutedEvent => true + case _ => false + } should be(true) // Verify the captured pawn was found (computeCaptured EnPassant branch) val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head @@ -84,8 +90,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // ── Bishop underpromotion notation (line 230) ────────────────────── test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"): - // Extra white pawn on h2 ensures K+B+P vs K — sufficient material, so draw is not triggered - val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get + val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get val ctx = GameContext.initial .withBoard(board) .withTurn(Color.White) @@ -106,8 +111,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, white rook on h1 — K+R vs K ensures sufficient material after the king move - val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get + // White king on e1, no castling rights, black king far away + val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get val ctx = GameContext.initial .withBoard(board) .withTurn(Color.White) @@ -118,7 +123,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // King moves e1 -> f1 engine.processUserInput("e1f1") - events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) + events.exists { + case _: MoveExecutedEvent => true + case _ => false + } should be(true) events.clear() engine.undo() 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 71df191..0742af6 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 @@ -29,7 +29,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.processUserInput("e7e8") - events.exists { case _: PromotionRequiredEvent => true; case _ => false } should be(true) + events.exists { + case _: PromotionRequiredEvent => true + case _ => false + } should be(true) events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7)) } @@ -60,7 +63,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: 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) - events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true) + events.exists { + case _: MoveExecutedEvent => true + case _ => false + } should be(true) } test("completePromotion with rook underpromotion") { @@ -80,7 +86,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.completePromotion(PromotionPiece.Queen) - events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true) + events.exists { + case _: InvalidMoveEvent => true + case _ => false + } should be(true) engine.isPendingPromotion should be(false) } @@ -92,7 +101,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Queen) - events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(true) + events.exists { + case _: CheckDetectedEvent => true + case _ => false + } should be(true) } test("completePromotion results in Moved when promotion doesn't give check") { @@ -105,8 +117,14 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.isPendingPromotion should be(false) engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen))) - events.collect { case e: MoveExecutedEvent => e } should not be empty - events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false) + events.filter { + case _: MoveExecutedEvent => true + case _ => false + } should not be empty + events.exists { + case _: CheckDetectedEvent => true + case _ => false + } should be(false) } test("completePromotion results in Checkmate when promotion delivers checkmate") { @@ -118,7 +136,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.completePromotion(PromotionPiece.Queen) engine.isPendingPromotion should be(false) - events.exists { case _: CheckmateEvent => true; case _ => false } should be(true) + events.exists { + case _: CheckmateEvent => true + case _ => false + } should be(true) } test("completePromotion results in Stalemate when promotion creates stalemate") { @@ -130,7 +151,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.completePromotion(PromotionPiece.Knight) engine.isPendingPromotion should be(false) - events.exists { case _: DrawEvent => true; case _ => false } should be(true) + events.exists { + case _: StalemateEvent => true + case _ => false + } should be(true) } test("completePromotion with black pawn promotion results in Moved") { @@ -143,8 +167,14 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.isPendingPromotion should be(false) engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen))) - events.collect { case e: MoveExecutedEvent => e } should not be empty - events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false) + events.filter { + case _: MoveExecutedEvent => true + case _ => false + } should not be empty + events.exists { + case _: CheckDetectedEvent => true + case _ => false + } should be(false) } test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") { @@ -193,7 +223,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.completePromotion(PromotionPiece.Queen) engine.isPendingPromotion should be(false) - events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true) + events.exists { + case _: InvalidMoveEvent => true + case _ => false + } should be(true) val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last invalidEvt.reason should include("Error completing promotion") } 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 524a69d..9ce33ff 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 @@ -3,11 +3,12 @@ package de.nowchess.chess.engine import de.nowchess.api.board.Color import de.nowchess.api.game.GameContext import de.nowchess.bot.bots.ClassicalBot -import de.nowchess.bot.{BotDifficulty, BotController} +import de.nowchess.bot.{BotController, BotDifficulty} import de.nowchess.chess.observer.* import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} import scala.concurrent.duration.* import scala.concurrent.Await @@ -15,26 +16,26 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers: test("GameEngine can play against a ClassicalBot"): val engine = GameEngine(GameContext.initial, DefaultRules) - val bot = ClassicalBot(BotDifficulty.Easy) + val bot = ClassicalBot(BotDifficulty.Easy) // Set White (human) vs Black (bot) engine.setOpponentBot(bot, Color.Black) // Collect events - var moveCount = 0 - var checkmateDetected = false - var gameEnded = false + val moveCount = new AtomicInteger(0) + val checkmateDetected = new AtomicBoolean(false) + val gameEnded = new AtomicBoolean(false) val observer = new Observer: def onGameEvent(event: GameEvent): Unit = event match case _: MoveExecutedEvent => - moveCount += 1 + moveCount.incrementAndGet() case _: CheckmateEvent => - checkmateDetected = true - gameEnded = true + checkmateDetected.set(true) + gameEnded.set(true) case _: StalemateEvent => - gameEnded = true + gameEnded.set(true) case _ => () engine.subscribe(observer) @@ -46,7 +47,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers: Thread.sleep(5000) // White should have moved, then Black (bot) should have responded - moveCount should be >= 2 + moveCount.get() should be >= 2 engine.clearOpponentBot() @@ -64,42 +65,42 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers: BotController.getBot("unknown") should be(None) test("GameEngine handles bot with different difficulty"): - val engine = GameEngine(GameContext.initial, DefaultRules) + val engine = GameEngine(GameContext.initial, DefaultRules) val hardBot = BotController.getBot("hard").get engine.setOpponentBot(hardBot, Color.Black) engine.turn should equal(Color.White) - var movesMade = 0 + val movesMade = new AtomicInteger(0) val observer = new Observer: def onGameEvent(event: GameEvent): Unit = event match - case _: MoveExecutedEvent => movesMade += 1 - case _ => () + case _: MoveExecutedEvent => movesMade.incrementAndGet() + case _ => () engine.subscribe(observer) // White moves engine.processUserInput("d2d4") - Thread.sleep(500) // Wait for bot response + Thread.sleep(500) // Wait for bot response // At least white moved, possibly black also responded - movesMade should be >= 1 + movesMade.get() should be >= 1 engine.clearOpponentBot() test("GameEngine plays valid bot moves"): val engine = GameEngine(GameContext.initial, DefaultRules) - val bot = ClassicalBot(BotDifficulty.Easy) + val bot = ClassicalBot(BotDifficulty.Easy) engine.setOpponentBot(bot, Color.Black) - var moveCount = 0 + val moveCount = new AtomicInteger(0) val observer = new Observer: def onGameEvent(event: GameEvent): Unit = event match - case _: MoveExecutedEvent => moveCount += 1 - case _ => () + case _: MoveExecutedEvent => moveCount.incrementAndGet() + case _ => () engine.subscribe(observer) @@ -108,7 +109,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers: Thread.sleep(1000) // The game should have progressed with at least one move - moveCount should be >= 1 + moveCount.get() should be >= 1 // Game should not be ended (checkmate/stalemate) engine.context.moves.nonEmpty should be(true) diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala index 63825d6..1f206ff 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala @@ -52,7 +52,7 @@ object FenParser extends GameContextImport: ) else None - /** Parse en passant target square ("-" for none, or algebraic like "e3"). */ + /** Parse en passant target square ("-" for none, or algebraic like "e3"). */ private def parseEnPassant(s: String): Option[Option[Square]] = if s == "-" then Some(None) else Square.fromAlgebraic(s).map(Some(_)) diff --git a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala index 670b96f..62d2f40 100644 --- a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala @@ -122,7 +122,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: val context = GameContext.initial val faultyExporter = new GameContextExport { def exportGameContext(c: GameContext): String = - throw new RuntimeException("Export failed") // scalafix:ok DisableSyntax.throw + sys.error("Export failed") } val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter) 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 f05f0a2..1e0523e 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 @@ -27,7 +27,7 @@ object DefaultRules extends RuleSet: List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2)) // ── Pawn configuration helpers ───────────────────────────────────── - private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1 + 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 @@ -36,13 +36,14 @@ object DefaultRules extends RuleSet: 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) + 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) } override def legalMoves(context: GameContext)(square: Square): List[Move] = @@ -51,13 +52,10 @@ object DefaultRules extends RuleSet: } override def allLegalMoves(context: GameContext): List[Move] = - context.board.pieces - .collect { case (sq, p) if p.color == context.turn => legalMoves(context)(sq) } - .flatten - .toList + Square.all.flatMap(sq => legalMoves(context)(sq)).toList override def isCheck(context: GameContext): Boolean = - context.kingSquare(context.turn) + kingSquare(context.board, context.turn) .fold(false)(sq => isAttackedBy(context.board, sq, context.turn.opposite)) override def isCheckmate(context: GameContext): Boolean = @@ -115,18 +113,18 @@ object DefaultRules extends RuleSet: // ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── private def slidingMoves( - context: GameContext, - from: Square, - color: Color, - dirs: List[(Int, Int)] + 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) + board: Board, + from: Square, + color: Color, + dir: (Int, Int), ): List[Move] = @tailrec def loop(sq: Square, acc: List[Move]): List[Move] = @@ -134,40 +132,40 @@ object DefaultRules extends RuleSet: case None => acc case Some(next) => board.pieceAt(next) match - case None => loop(next, Move(from, next) :: acc) + 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 + case Some(_) => acc loop(from, Nil).reverse // ── Knight ───────────────────────────────────────────────────────── private def knightCandidates( - context: GameContext, - from: Square, - color: Color + 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)) + 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 + 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)) + case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) + case None => Some(Move(from, to)) } } steps ++ castlingCandidates(context, from, color) @@ -175,17 +173,17 @@ object DefaultRules extends RuleSet: // ── Castling ─────────────────────────────────────────────────────── private case class CastlingMove( - kingFromAlg: String, - kingToAlg: String, - middleAlg: String, - rookFromAlg: String, - moveType: MoveType + kingFromAlg: String, + kingToAlg: String, + middleAlg: String, + rookFromAlg: String, + moveType: MoveType, ) private def castlingCandidates( - context: GameContext, - from: Square, - color: Color + context: GameContext, + from: Square, + color: Color, ): List[Move] = color match case Color.White => whiteCastles(context, from) @@ -196,10 +194,18 @@ object DefaultRules extends RuleSet: 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)) + 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] = @@ -207,10 +213,18 @@ object DefaultRules extends RuleSet: 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)) + 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] = @@ -220,10 +234,10 @@ object DefaultRules extends RuleSet: case _ => List.empty private def addCastleMove( - context: GameContext, - moves: scala.collection.mutable.ListBuffer[Move], - castlingRight: Boolean, - castlingMove: CastlingMove + 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)) @@ -235,16 +249,15 @@ object DefaultRules extends RuleSet: kt <- Square.fromAlgebraic(castlingMove.kingToAlg) rf <- Square.fromAlgebraic(castlingMove.rookFromAlg) do - val color = context.turn + 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) + !isAttackedBy(context.board, km, color.opposite) && + !isAttackedBy(context.board, kt, color.opposite) - if kingPresent && rookPresent && squaresSafe then - moves += Move(kf, kt, castlingMove.moveType) + 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) @@ -252,22 +265,26 @@ object DefaultRules extends RuleSet: // ── Pawn ─────────────────────────────────────────────────────────── private def pawnCandidates( - context: GameContext, - from: Square, - color: Color + context: GameContext, + from: Square, + color: Color, ): List[Move] = - val fwd = pawnForward(color) + 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 + 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 + .flatten val diagonalCaptures = List(-1, 1).flatMap { df => from.offset(df, fwd).flatMap { to => @@ -286,55 +303,56 @@ object DefaultRules extends RuleSet: def toMoves(dest: Square, isCapture: Boolean): List[Move] = if dest.rank.ordinal == promoRank then List( - PromotionPiece.Queen, PromotionPiece.Rook, - PromotionPiece.Bishop, PromotionPiece.Knight + 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 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 ──────────────────────────────────────────────── - /** Cast rays outward from `target` to detect attackers — O(rays) instead of O(64×rays). */ + 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 = - attackedBySlider(board, target, attacker, RookDirs, PieceType.Rook) || - attackedBySlider(board, target, attacker, BishopDirs, PieceType.Bishop) || - attackedByKnight(board, target, attacker) || - attackedByPawn(board, target, attacker) || - attackedByKing(board, target, attacker) + Square.all.exists { sq => + board.pieceAt(sq).fold(false) { p => + p.color == attacker && squareAttacks(board, sq, p, target) + } + } - private def attackedBySlider(board: Board, target: Square, attacker: Color, dirs: List[(Int, Int)], sliderType: PieceType): Boolean = + 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) => board.pieceAt(next) match - case None => loop(next) - case Some(p) if p.color == attacker && (p.pieceType == sliderType || p.pieceType == PieceType.Queen) => true - case _ => false - loop(target) - } - - private def attackedByKnight(board: Board, target: Square, attacker: Color): Boolean = - KnightJumps.exists { (df, dr) => - target.offset(df, dr).exists(sq => board.pieceAt(sq).exists(p => p.color == attacker && p.pieceType == PieceType.Knight)) - } - - private def attackedByPawn(board: Board, target: Square, attacker: Color): Boolean = - val dr = if attacker == Color.White then -1 else 1 - List(-1, 1).exists { df => - target.offset(df, dr).exists(sq => board.pieceAt(sq).exists(p => p.color == attacker && p.pieceType == PieceType.Pawn)) - } - - private def attackedByKing(board: Board, target: Square, attacker: Color): Boolean = - QueenDirs.exists { (df, dr) => - target.offset(df, dr).exists(sq => board.pieceAt(sq).exists(p => p.color == attacker && p.pieceType == PieceType.King)) + @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 nextBoard = context.board.applyMove(move) val nextContext = context.withBoard(nextBoard) isCheck(nextContext) @@ -342,7 +360,7 @@ object DefaultRules extends RuleSet: 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) @@ -351,14 +369,14 @@ object DefaultRules extends RuleSet: case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp) case MoveType.Normal(_) => board.applyMove(move) - val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) + val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) val newEnPassantSquare = computeEnPassantSquare(board, move) val isCapture = move.moveType match case MoveType.Normal(capture) => capture case MoveType.EnPassant => true case _ => board.pieceAt(move.to).isDefined val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) - val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1 + val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1 context .withBoard(newBoard) @@ -371,19 +389,18 @@ object DefaultRules extends RuleSet: 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) = - if kingside then - (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) - else - (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank)) + if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) + 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) + .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 capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn val capturedSquare = Square(move.to.file, capturedRank) board.applyMove(move).removed(capturedSquare) @@ -396,7 +413,7 @@ object DefaultRules extends RuleSet: board.removed(move.from).updated(move.to, Piece(color, promotedType)) private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights = - val piece = board.pieceAt(move.from) + val piece = board.pieceAt(move.from) val isKingMove = piece.exists(_.pieceType == PieceType.King) val isRookMove = piece.exists(_.pieceType == PieceType.Rook) @@ -406,19 +423,25 @@ object DefaultRules extends RuleSet: val blackKingsideRook = Square(File.H, Rank.R8) val blackQueensideRook = Square(File.A, Rank.R8) - var r = rights - if isKingMove then r = r.revokeColor(color) - else if isRookMove then - if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White) - if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White) - if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black) - if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black) + val afterKingMove = if isKingMove then rights.revokeColor(color) else rights + + val afterRookMove = + if !isRookMove then afterKingMove + else + move.from match + case `whiteKingsideRook` => afterKingMove.revokeKingSide(Color.White) + case `whiteQueensideRook` => afterKingMove.revokeQueenSide(Color.White) + case `blackKingsideRook` => afterKingMove.revokeKingSide(Color.Black) + case `blackQueensideRook` => afterKingMove.revokeQueenSide(Color.Black) + case _ => afterKingMove + // Also revoke if a rook is captured - if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White) - if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White) - if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black) - if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black) - r + move.to match + case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White) + case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White) + case `blackKingsideRook` => afterRookMove.revokeKingSide(Color.Black) + case `blackQueensideRook` => afterRookMove.revokeQueenSide(Color.Black) + case _ => afterRookMove private def computeEnPassantSquare(board: Board, move: Move): Option[Square] = val piece = board.pieceAt(move.from) @@ -435,9 +458,10 @@ object DefaultRules extends RuleSet: private def insufficientMaterial(board: Board): Boolean = val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King) pieces match - case Nil => true + case Nil => true case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true case List(p1, p2) - if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop - && p1.color != p2.color => true + if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop + && p1.color != p2.color => + true case _ => false diff --git a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala index 92d8d89..9f0ea3d 100644 --- a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala @@ -29,10 +29,15 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: test("pawn can capture diagonally"): // FEN: white pawn e4, black pawn d5 - val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" - val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) - val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && (m.moveType match { case _: MoveType.Normal => true; case _ => false })) + val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" + val context = FenParser.parseFen(fen).fold(_ => fail(), identity) + val moves = rules.allLegalMoves(context) + val captures = moves.filter { m => + m.from == Square(File.E, Rank.R4) && (m.moveType match + case _: MoveType.Normal => true + case _ => false + ) + } captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true test("pawn cannot move backward"): diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts index 43aa3bd..6460c2c 100644 --- a/modules/ui/build.gradle.kts +++ b/modules/ui/build.gradle.kts @@ -38,6 +38,11 @@ tasks.withType { scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") } +// Disable scalafix for UI module due to mutable state requirements +tasks.matching { it.name.startsWith("scalafix") }.configureEach { + enabled = false +} + tasks.named("run") { jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8") standardInput = System.`in` diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala index 48ff272..0c442f0 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala @@ -1,6 +1,6 @@ package de.nowchess.ui -import de.nowchess.api.board.Color.Black +import de.nowchess.api.board.Color.{Black, White} import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.BotDifficulty import de.nowchess.bot.bots.{ClassicalBot, HybridBot, NNUEBot} @@ -16,10 +16,9 @@ object Main: // Create the core game engine (single source of truth) val engine = new GameEngine() - val book = PolyglotBook("../../modules/bot/codekiddy.bin") - engine.setOpponentBot(HybridBot(BotDifficulty.Easy, book = Some(book)), Black); + engine.setOpponentBot(HybridBot(BotDifficulty.Easy, book = Some(book)), White); // Launch ScalaFX GUI in separate thread ChessGUILauncher.launch(engine) 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 f059250..a8463ca 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,24 +6,26 @@ 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, Option[Image]]() + private val spriteCache = scala.collection.mutable.Map[String, Image]() /** Load a piece sprite image from resources. Sprites are cached for performance. */ - 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 - } + 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 } - private def loadImage(key: String): Option[Image] = + private def loadImage(key: String): Image = val path = s"/sprites/pieces/$key.png" - Option(getClass.getResourceAsStream(path)).map(new Image(_)) + Option(getClass.getResourceAsStream(path)) match + case Some(stream) => new Image(stream) + case None => sys.error(s"Could not load sprite: $path") /** Get square colors for the board using theme. */ object SquareColors: