feat: Improved how NNUE Evalutes

This commit is contained in:
2026-04-13 17:37:24 +02:00
parent ed26406185
commit 5df5a1875f
23 changed files with 438 additions and 292 deletions
+27 -11
View File
@@ -94,18 +94,34 @@
- function getBot - function getBot
- function listBots - function listBots
- `modules/bot/src/main/scala/de/nowchess/bot/Config.scala` — class Config - `modules/bot/src/main/scala/de/nowchess/bot/Config.scala` — class Config
- `modules/bot/src/main/scala/de/nowchess/bot/ai/Weights.scala` - `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala`
- class Weights - class Evaluation
- class CHECKMATE_SCORE - class CHECKMATE_SCORE
- class DRAW_SCORE - class DRAW_SCORE
- function evaluate - 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/EvaluationNNUE.scala` — class EvaluationNNUE, function evaluate
- `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala` - `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`
- class NNUE - class NNUE
- function initAccumulator
- function pushAccumulator
- function copyAccumulator
- function evaluateAtPly
- function evaluate - function evaluate
- function benchmark - _...1 more_
- `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala` — function bestMove, function bestMoveWithTime - `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` - `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`
- class MoveOrdering - class MoveOrdering
- class OrderingContext - class OrderingContext
@@ -118,7 +134,7 @@
- function probe - function probe
- function store - function store
- function clear - 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/PolyglotHash.scala` — class PolyglotHash, function hash
- `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala` - `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`
- class ZobristHash - 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/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/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/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/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/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/sets/DefaultRules.scala` — imported by **17** files
- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **10** 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 - `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files
@@ -275,13 +291,13 @@
## Import Map (who imports what) ## 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/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/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/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/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/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/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 - `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
+5 -5
View File
@@ -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/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/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/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/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/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/sets/DefaultRules.scala` — imported by **17** files
- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **10** 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 - `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files
@@ -25,13 +25,13 @@
## Import Map (who imports what) ## 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/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/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/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/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/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/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 - `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
+22 -6
View File
@@ -85,18 +85,34 @@
- function getBot - function getBot
- function listBots - function listBots
- `modules/bot/src/main/scala/de/nowchess/bot/Config.scala` — class Config - `modules/bot/src/main/scala/de/nowchess/bot/Config.scala` — class Config
- `modules/bot/src/main/scala/de/nowchess/bot/ai/Weights.scala` - `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala`
- class Weights - class Evaluation
- class CHECKMATE_SCORE - class CHECKMATE_SCORE
- class DRAW_SCORE - class DRAW_SCORE
- function evaluate - 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/EvaluationNNUE.scala` — class EvaluationNNUE, function evaluate
- `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala` - `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`
- class NNUE - class NNUE
- function initAccumulator
- function pushAccumulator
- function copyAccumulator
- function evaluateAtPly
- function evaluate - function evaluate
- function benchmark - _...1 more_
- `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala` — function bestMove, function bestMoveWithTime - `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` - `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`
- class MoveOrdering - class MoveOrdering
- class OrderingContext - class OrderingContext
@@ -109,7 +125,7 @@
- function probe - function probe
- function store - function store
- function clear - 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/PolyglotHash.scala` — class PolyglotHash, function hash
- `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala` - `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`
- class ZobristHash - class ZobristHash
@@ -4,7 +4,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch 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.bot.{Bot, BotDifficulty}
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -22,11 +22,36 @@ final class NNUEBot(
override def nextMove(context: GameContext): Option[Move] = override def nextMove(context: GameContext): Option[Move] =
book book
.flatMap(_.probe(context)) .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. */ /** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score
private def allocateTime(context: GameContext): Long = * from the root player's perspective.
val moveCount = rules.allLegalMoves(context).length */
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 if moveCount > 30 then 1500L
else if moveCount < 5 then 500L 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
@@ -249,7 +249,7 @@ object EvaluationClassic extends Evaluation:
@scala.annotation.tailrec @scala.annotation.tailrec
def countRay(current: Option[Square], acc: Int): Int = def countRay(current: Option[Square], acc: Int): Int =
current match current match
case None => acc case None => acc
case Some(target) => case Some(target) =>
board.pieceAt(target) match board.pieceAt(target) match
case Some(piece) if piece.color == color => acc case Some(piece) if piece.color == color => acc
@@ -287,10 +287,10 @@ object EvaluationClassic extends Evaluation:
val friendlyHasPair = val friendlyHasPair =
friendlyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) && 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 = val enemyHasPair =
enemyBishops.exists((sq, _) => (sq.file.ordinal + sq.rank.ordinal) % 2 == 0) && 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 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) val baseEg = (if friendlyHasPair then bishopPairEg else 0) - (if enemyHasPair then bishopPairEg else 0)
@@ -312,7 +312,7 @@ object EvaluationClassic extends Evaluation:
val kingCentralBonus = val kingCentralBonus =
friendlyKing.fold(0)((kSq, _) => (8 - kingCentralizationDistance(kSq)) * 15) - 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 friendlyMaterial = materialCount(context, context.turn)
val enemyMaterial = materialCount(context, context.turn.opposite) val enemyMaterial = materialCount(context, context.turn.opposite)
@@ -15,8 +15,7 @@ class NNUE:
// l1WeightsT(featureIdx * 1536 + outputIdx) = l1Weights(outputIdx * 768 + featureIdx) // l1WeightsT(featureIdx * 1536 + outputIdx) = l1Weights(outputIdx * 768 + featureIdx)
private val l1WeightsT: Array[Float] = private val l1WeightsT: Array[Float] =
val t = new Array[Float](768 * 1536) val t = new Array[Float](768 * 1536)
for j <- 0 until 768; i <- 0 until 1536 do for j <- 0 until 768; i <- 0 until 1536 do t(j * 1536 + i) = l1Weights(i * 768 + j)
t(j * 1536 + i) = l1Weights(i * 768 + j)
t t
private def loadWeights(): ( private def loadWeights(): (
@@ -42,11 +42,14 @@ final class AlphaBetaSearch(
timeLimitMs.set(Long.MaxValue / 4) timeLimitMs.set(Long.MaxValue / 4)
nodeCount.set(0) nodeCount.set(0)
val rootHash = ZobristHash.hash(context) val rootHash = ZobristHash.hash(context)
(1 to maxDepth).foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) => (1 to maxDepth)
val (alpha, beta) = if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA) .foldLeft((None: Option[Move], 0)) { case ((bestSoFar, prevScore), depth) =>
val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash) val (alpha, beta) =
(move.orElse(bestSoFar), score) if depth == 1 then (-INF, INF) else (prevScore - ASPIRATION_DELTA, prevScore + ASPIRATION_DELTA)
}._1 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 /** Return the best move for the side to move within a time budget (ms). Uses iterative deepening, stopping when time
* runs out. * runs out.
@@ -64,7 +67,8 @@ final class AlphaBetaSearch(
def loop(bestSoFar: Option[Move], prevScore: Int, depth: Int): Option[Move] = def loop(bestSoFar: Option[Move], prevScore: Int, depth: Int): Option[Move] =
if isOutOfTime then bestSoFar if isOutOfTime then bestSoFar
else 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) val (score, move) = searchWithAspiration(context, depth, alpha, beta, ASPIRATION_DELTA, rootHash)
loop(move.orElse(bestSoFar), score, depth + 1) loop(move.orElse(bestSoFar), score, depth + 1)
@@ -85,15 +89,13 @@ final class AlphaBetaSearch(
@scala.annotation.tailrec @scala.annotation.tailrec
def loop(currentAlpha: Int, currentBeta: Int, window: Int, attempt: Int): (Int, Option[Move]) = def loop(currentAlpha: Int, currentBeta: Int, window: Int, attempt: Int): (Int, Option[Move]) =
if attempt >= 3 || attempt >= depth then if attempt >= 3 || attempt >= depth then search(context, depth, 0, -INF, INF, rootHash, repetitions)
search(context, depth, 0, -INF, INF, rootHash, repetitions)
else else
val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, rootHash, repetitions) val (score, move) = search(context, depth, 0, currentAlpha, currentBeta, rootHash, repetitions)
if score > currentAlpha && score < currentBeta then (score, move) if score > currentAlpha && score < currentBeta then (score, move)
else if score <= currentAlpha then else if score <= currentAlpha then
loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1) loop(score - window, currentBeta, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
else else loop(currentAlpha, score + window, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
loop(currentAlpha, score + window, math.min(window * 2, ASPIRATION_DELTA_MAX), attempt + 1)
loop(alpha, beta, initialWindow, 0) loop(alpha, beta, initialWindow, 0)
@@ -136,10 +138,8 @@ final class AlphaBetaSearch(
repetitions: Map[Long, Int], repetitions: Map[Long, Int],
): (Int, Option[Move]) = ): (Int, Option[Move]) =
val count = nodeCount.incrementAndGet() val count = nodeCount.incrementAndGet()
if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then if count % TIME_CHECK_FREQUENCY == 0 && isOutOfTime then (weights.evaluateAccumulator(ply, context, hash), None)
(weights.evaluateAccumulator(ply, context, hash), None) else if repetitions.getOrElse(hash, 0) >= 3 then (weights.DRAW_SCORE, None)
else if repetitions.getOrElse(hash, 0) >= 3 then
(weights.DRAW_SCORE, None)
else else
val ttCutoff = tt.probe(hash).filter(_.depth >= depth).flatMap { entry => val ttCutoff = tt.probe(hash).filter(_.depth >= depth).flatMap { entry =>
entry.flag match entry.flag match
@@ -155,10 +155,8 @@ final class AlphaBetaSearch(
val legalMoves = rules.allLegalMoves(context) val legalMoves = rules.allLegalMoves(context)
if legalMoves.isEmpty then if legalMoves.isEmpty then
(if rules.isCheckmate(context) then -(weights.CHECKMATE_SCORE - ply) else weights.DRAW_SCORE, None) (if rules.isCheckmate(context) then -(weights.CHECKMATE_SCORE - ply) else weights.DRAW_SCORE, None)
else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then else if rules.isInsufficientMaterial(context) || rules.isFiftyMoveRule(context) then (weights.DRAW_SCORE, None)
(weights.DRAW_SCORE, None) else if depth == 0 then (quiescence(context, ply, alpha, beta, hash), None)
else if depth == 0 then
(quiescence(context, ply, alpha, beta, hash), None)
else else
val nullResult = Option val nullResult = Option
.when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) { .when(depth >= 3 && !rules.isCheck(context) && hasNonPawnMaterial(context)) {
@@ -192,7 +190,7 @@ final class AlphaBetaSearch(
): (Option[Move], Int, Boolean) = ): (Option[Move], Int, Boolean) =
if idx >= ordered.length then (bestMove, bestScore, false) if idx >= ordered.length then (bestMove, bestScore, false)
else else
val move = ordered(idx) val move = ordered(idx)
val isQuiet = !isCapture(context, move) && val isQuiet = !isCapture(context, move) &&
move.moveType != MoveType.CastleKingside && move.moveType != MoveType.CastleKingside &&
move.moveType != MoveType.CastleQueenside move.moveType != MoveType.CastleQueenside
@@ -233,11 +231,14 @@ final class AlphaBetaSearch(
if newA >= beta then if newA >= beta then
if isQuiet 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) ordering.addKillerMove(ply, move)
(newBestMove, newBestScore, true) (newBestMove, newBestScore, true)
else else loop(idx + 1, newBestMove, newBestScore, newA, moveNumber + 1)
loop(idx + 1, newBestMove, newBestScore, newA, moveNumber + 1)
val (bestMove, bestScore, cutoff) = loop(0, None, -INF, alpha, 0) val (bestMove, bestScore, cutoff) = loop(0, None, -INF, alpha, 0)
val flag = val flag =
@@ -262,8 +263,7 @@ final class AlphaBetaSearch(
else else
val a0 = if inCheck then alpha else math.max(alpha, standPat) val a0 = if inCheck then alpha else math.max(alpha, standPat)
if ply >= MAX_QUIESCENCE_PLY then if ply >= MAX_QUIESCENCE_PLY then if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat
if inCheck then weights.evaluateAccumulator(ply, context, hash) else standPat
else else
val allMoves = rules.allLegalMoves(context) val allMoves = rules.allLegalMoves(context)
val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m)) val tacticalMoves = if inCheck then allMoves else allMoves.filter(m => isCapture(context, m))
@@ -34,5 +34,4 @@ final class TranspositionTable(val sizePow2: Int = 20):
} }
def clear(): Unit = def clear(): Unit =
for i <- 0 until size do for i <- 0 until size do locks(i).synchronized { table(i) = None }
locks(i).synchronized { table(i) = None }
@@ -82,15 +82,15 @@ final class PolyglotBook(path: String):
if isKingMove(context, from) && isRookSquare(to, context) then Some(decodeCastling(from, to)) if isKingMove(context, from) && isRookSquare(to, context) then Some(decodeCastling(from, to))
else else
val moveTypeOpt: Option[MoveType] = if promotionBits > 0 then val moveTypeOpt: Option[MoveType] =
promotionBits match if promotionBits > 0 then
case 1 => Some(MoveType.Promotion(PromotionPiece.Knight)) promotionBits match
case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop)) case 1 => Some(MoveType.Promotion(PromotionPiece.Knight))
case 3 => Some(MoveType.Promotion(PromotionPiece.Rook)) case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop))
case 4 => Some(MoveType.Promotion(PromotionPiece.Queen)) case 3 => Some(MoveType.Promotion(PromotionPiece.Rook))
case _ => None case 4 => Some(MoveType.Promotion(PromotionPiece.Queen))
else case _ => None
Some(MoveType.Normal(context.board.pieces.contains(to))) else Some(MoveType.Normal(context.board.pieces.contains(to)))
moveTypeOpt.map(moveType => Move(from, to, moveType)) moveTypeOpt.map(moveType => Move(from, to, moveType))
@@ -74,7 +74,7 @@ object ZobristHash:
context.board.pieceAt(move.from).fold(h0) { pawn => context.board.pieceAt(move.from).fold(h0) { pawn =>
val capturedSquare = Square(move.to.file, move.from.rank) val capturedSquare = Square(move.to.file, move.from.rank)
val h1 = h0 ^ pieceKey(move.from, pawn) 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) h2 ^ pieceKey(move.to, pawn)
} }
@@ -106,9 +106,12 @@ object ZobristHash:
case PromotionPiece.Queen => PieceType.Queen case PromotionPiece.Queen => PieceType.Queen
private def toggleCastling(h0: Long, before: GameContext, after: GameContext): Long = 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 h1 =
val h2 = if before.castlingRights.whiteQueenSide != after.castlingRights.whiteQueenSide then h1 ^ castlingRands(1) else h1 if before.castlingRights.whiteKingSide != after.castlingRights.whiteKingSide then h0 ^ castlingRands(0) else h0
val h3 = if before.castlingRights.blackKingSide != after.castlingRights.blackKingSide then h2 ^ castlingRands(2) else h2 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 if before.castlingRights.blackQueenSide != after.castlingRights.blackQueenSide then h3 ^ castlingRands(3) else h3
private def toggleEnPassant(h0: Long, before: GameContext, after: GameContext): Long = private def toggleEnPassant(h0: Long, before: GameContext, after: GameContext): Long =
@@ -10,7 +10,7 @@ import de.nowchess.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import de.nowchess.bot.Bot 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 /** 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. * 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 private var pendingPromotion: Option[PendingPromotion] = None
/** Optional opponent bot and the color it plays. */ /** Optional opponent bot and the color it plays. */
@SuppressWarnings(Array("DisableSyntax.var"))
private var opponentBot: Option[Bot] = None 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 private implicit val ec: ExecutionContext = ExecutionContext.global
/** True if a pawn promotion move is pending and needs a piece choice. */ /** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined) def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
/** Set an opponent bot to play against. /** Set an opponent bot to play against. The bot will play as the given color and auto-play moves after the opponent
* The bot will play as the given color and auto-play moves after the opponent moves. * moves.
*/ */
def setOpponentBot(bot: Bot, color: Color): Unit = synchronized { def setOpponentBot(bot: Bot, color: Color): Unit = synchronized {
opponentBot = Some(bot) opponentBot = Some(bot)
opponentColor = Some(color) opponentColor = Some(color)
@@ -275,9 +277,8 @@ class GameEngine(
// Request bot move if it's the opponent bot's turn // Request bot move if it's the opponent bot's turn
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
() // Game is over, don't request bot move () // Game is over, don't request bot move
else else requestBotMoveIfNeeded()
requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String = private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match move.moveType match
@@ -328,24 +329,23 @@ class GameEngine(
case _ => case _ =>
context.board.pieceAt(move.to) context.board.pieceAt(move.to)
/** Request a move from the opponent bot if it's their turn. /** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
* Spawns an async task to avoid blocking the engine. */
*/
private def requestBotMoveIfNeeded(): Unit = private def requestBotMoveIfNeeded(): Unit =
(opponentBot, opponentColor) match (opponentBot, opponentColor) match
case (Some(bot), Some(color)) if currentContext.turn == color => case (Some(bot), Some(color)) if currentContext.turn == color =>
Future { Future {
bot.nextMove(currentContext) match bot.nextMove(currentContext) match
case Some(move) => applyBotMove(move, color) 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 = private def applyBotMove(move: Move, color: Color): Unit =
synchronized { synchronized {
if currentContext.turn == color then if currentContext.turn == color then
val from = move.from val from = move.from
val to = move.to val to = move.to
currentContext.board.pieceAt(from) match currentContext.board.pieceAt(from) match
case Some(piece) if piece.color == color => case Some(piece) if piece.color == color =>
val legal = ruleSet.legalMoves(currentContext)(from) val legal = ruleSet.legalMoves(currentContext)(from)
@@ -353,13 +353,12 @@ class GameEngine(
case Some(legalMove) => case Some(legalMove) =>
val isPromotion = move.moveType match val isPromotion = move.moveType match
case MoveType.Promotion(_) => true case MoveType.Promotion(_) => true
case _ => false case _ => false
if isPromotion then if isPromotion then
move.moveType match move.moveType match
case MoveType.Promotion(pp) => completePromotion(pp) case MoveType.Promotion(pp) => completePromotion(pp)
case _ => () case _ => ()
else else executeMove(legalMove)
executeMove(legalMove)
case None => case None =>
notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal")) notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal"))
case _ => case _ =>
@@ -371,8 +370,7 @@ class GameEngine(
if ruleSet.isCheckmate(currentContext) then if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner)) notifyObservers(CheckmateEvent(currentContext, winner))
else if ruleSet.isStalemate(currentContext) then else if ruleSet.isStalemate(currentContext) then notifyObservers(StalemateEvent(currentContext))
notifyObservers(StalemateEvent(currentContext))
} }
private def performUndo(): Unit = private def performUndo(): Unit =
@@ -1,9 +1,8 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.Color import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.DrawReason import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -24,10 +23,15 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
engine.processUserInput("d8h4") engine.processUserInput("d8h4")
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent) // 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 // Board should be reset after checkmate
event.winner shouldBe Color.Black engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine handles check detection"): test("GameEngine handles check detection"):
val engine = new GameEngine() val engine = new GameEngine()
@@ -83,9 +87,12 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
observer.events.clear() observer.events.clear()
engine.processUserInput(moves.last) engine.processUserInput(moves.last)
val drawEvents = observer.events.collect { case e: DrawEvent => e } val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
drawEvents.size shouldBe 1 stalemateEvents.size shouldBe 1
drawEvents.head.reason shouldBe DrawReason.Stalemate
// Board should be reset after stalemate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
private class EndingMockObserver extends Observer: private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]() val events = mutable.ListBuffer[GameEvent]()
@@ -38,7 +38,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.processUserInput("undo") engine.processUserInput("undo")
engine.processUserInput("redo") 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"): test("processUserInput emits Illegal move for syntactically valid but illegal target"):
val engine = new GameEngine() val engine = new GameEngine()
@@ -69,7 +72,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.context shouldBe target engine.context shouldBe target
engine.commandHistory shouldBe empty 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"): test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine() val engine = new GameEngine()
@@ -43,7 +43,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White castles queenside: e1c1 // White castles queenside: e1c1
engine.processUserInput("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() events.clear()
engine.undo() engine.undo()
@@ -68,7 +71,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White pawn on e5 captures en passant to d6 // White pawn on e5 captures en passant to d6
engine.processUserInput("e5d6") 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) // Verify the captured pawn was found (computeCaptured EnPassant branch)
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
@@ -84,8 +90,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── Bishop underpromotion notation (line 230) ────────────────────── // ── Bishop underpromotion notation (line 230) ──────────────────────
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"): 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/k7/7K").get
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
.withTurn(Color.White) .withTurn(Color.White)
@@ -106,8 +111,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── King normal move notation (line 246) ─────────────────────────── // ── King normal move notation (line 246) ───────────────────────────
test("undo after king move emits MoveUndoneEvent with K notation"): 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 // White king on e1, no castling rights, black king far away
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
.withTurn(Color.White) .withTurn(Color.White)
@@ -118,7 +123,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// King moves e1 -> f1 // King moves e1 -> f1
engine.processUserInput("e1f1") 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() events.clear()
engine.undo() engine.undo()
@@ -29,7 +29,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8") 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)) 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.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None) engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) 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") { test("completePromotion with rook underpromotion") {
@@ -80,7 +86,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen) 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) engine.isPendingPromotion should be(false)
} }
@@ -92,7 +101,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen) 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") { 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.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.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
events.collect { case e: MoveExecutedEvent => e } should not be empty events.filter {
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false) 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") { test("completePromotion results in Checkmate when promotion delivers checkmate") {
@@ -118,7 +136,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be(false) 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") { test("completePromotion results in Stalemate when promotion creates stalemate") {
@@ -130,7 +151,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Knight) engine.completePromotion(PromotionPiece.Knight)
engine.isPendingPromotion should be(false) 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") { 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.isPendingPromotion should be(false)
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen))) 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.filter {
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false) 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") { 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.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be(false) 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 val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
invalidEvt.reason should include("Error completing promotion") invalidEvt.reason should include("Error completing promotion")
} }
@@ -3,11 +3,12 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.bot.bots.ClassicalBot 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.chess.observer.*
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
import scala.concurrent.duration.* import scala.concurrent.duration.*
import scala.concurrent.Await import scala.concurrent.Await
@@ -15,26 +16,26 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
test("GameEngine can play against a ClassicalBot"): test("GameEngine can play against a ClassicalBot"):
val engine = GameEngine(GameContext.initial, DefaultRules) val engine = GameEngine(GameContext.initial, DefaultRules)
val bot = ClassicalBot(BotDifficulty.Easy) val bot = ClassicalBot(BotDifficulty.Easy)
// Set White (human) vs Black (bot) // Set White (human) vs Black (bot)
engine.setOpponentBot(bot, Color.Black) engine.setOpponentBot(bot, Color.Black)
// Collect events // Collect events
var moveCount = 0 val moveCount = new AtomicInteger(0)
var checkmateDetected = false val checkmateDetected = new AtomicBoolean(false)
var gameEnded = false val gameEnded = new AtomicBoolean(false)
val observer = new Observer: val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = def onGameEvent(event: GameEvent): Unit =
event match event match
case _: MoveExecutedEvent => case _: MoveExecutedEvent =>
moveCount += 1 moveCount.incrementAndGet()
case _: CheckmateEvent => case _: CheckmateEvent =>
checkmateDetected = true checkmateDetected.set(true)
gameEnded = true gameEnded.set(true)
case _: StalemateEvent => case _: StalemateEvent =>
gameEnded = true gameEnded.set(true)
case _ => () case _ => ()
engine.subscribe(observer) engine.subscribe(observer)
@@ -46,7 +47,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
Thread.sleep(5000) Thread.sleep(5000)
// White should have moved, then Black (bot) should have responded // White should have moved, then Black (bot) should have responded
moveCount should be >= 2 moveCount.get() should be >= 2
engine.clearOpponentBot() engine.clearOpponentBot()
@@ -64,42 +65,42 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
BotController.getBot("unknown") should be(None) BotController.getBot("unknown") should be(None)
test("GameEngine handles bot with different difficulty"): 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 val hardBot = BotController.getBot("hard").get
engine.setOpponentBot(hardBot, Color.Black) engine.setOpponentBot(hardBot, Color.Black)
engine.turn should equal(Color.White) engine.turn should equal(Color.White)
var movesMade = 0 val movesMade = new AtomicInteger(0)
val observer = new Observer: val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = def onGameEvent(event: GameEvent): Unit =
event match event match
case _: MoveExecutedEvent => movesMade += 1 case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => () case _ => ()
engine.subscribe(observer) engine.subscribe(observer)
// White moves // White moves
engine.processUserInput("d2d4") 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 // At least white moved, possibly black also responded
movesMade should be >= 1 movesMade.get() should be >= 1
engine.clearOpponentBot() engine.clearOpponentBot()
test("GameEngine plays valid bot moves"): test("GameEngine plays valid bot moves"):
val engine = GameEngine(GameContext.initial, DefaultRules) val engine = GameEngine(GameContext.initial, DefaultRules)
val bot = ClassicalBot(BotDifficulty.Easy) val bot = ClassicalBot(BotDifficulty.Easy)
engine.setOpponentBot(bot, Color.Black) engine.setOpponentBot(bot, Color.Black)
var moveCount = 0 val moveCount = new AtomicInteger(0)
val observer = new Observer: val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = def onGameEvent(event: GameEvent): Unit =
event match event match
case _: MoveExecutedEvent => moveCount += 1 case _: MoveExecutedEvent => moveCount.incrementAndGet()
case _ => () case _ => ()
engine.subscribe(observer) engine.subscribe(observer)
@@ -108,7 +109,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
Thread.sleep(1000) Thread.sleep(1000)
// The game should have progressed with at least one move // 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) // Game should not be ended (checkmate/stalemate)
engine.context.moves.nonEmpty should be(true) engine.context.moves.nonEmpty should be(true)
@@ -52,7 +52,7 @@ object FenParser extends GameContextImport:
) )
else None 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]] = private def parseEnPassant(s: String): Option[Option[Square]] =
if s == "-" then Some(None) if s == "-" then Some(None)
else Square.fromAlgebraic(s).map(Some(_)) else Square.fromAlgebraic(s).map(Some(_))
@@ -122,7 +122,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val context = GameContext.initial val context = GameContext.initial
val faultyExporter = new GameContextExport { val faultyExporter = new GameContextExport {
def exportGameContext(c: GameContext): String = 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) val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
@@ -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)) List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
// ── Pawn configuration helpers ───────────────────────────────────── // ── 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 pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0 private def 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] = override def candidateMoves(context: GameContext)(square: Square): List[Move] =
context.board.pieceAt(square).fold(List.empty[Move]) { piece => context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
if piece.color != context.turn then List.empty[Move] if piece.color != context.turn then List.empty[Move]
else piece.pieceType match else
case PieceType.Pawn => pawnCandidates(context, square, piece.color) piece.pieceType match
case PieceType.Knight => knightCandidates(context, square, piece.color) case PieceType.Pawn => pawnCandidates(context, square, piece.color)
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) case PieceType.Knight => knightCandidates(context, square, piece.color)
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs) case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs) case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
case PieceType.King => kingCandidates(context, square, piece.color) 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] = override def legalMoves(context: GameContext)(square: Square): List[Move] =
@@ -51,13 +52,10 @@ object DefaultRules extends RuleSet:
} }
override def allLegalMoves(context: GameContext): List[Move] = override def allLegalMoves(context: GameContext): List[Move] =
context.board.pieces Square.all.flatMap(sq => legalMoves(context)(sq)).toList
.collect { case (sq, p) if p.color == context.turn => legalMoves(context)(sq) }
.flatten
.toList
override def isCheck(context: GameContext): Boolean = 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)) .fold(false)(sq => isAttackedBy(context.board, sq, context.turn.opposite))
override def isCheckmate(context: GameContext): Boolean = override def isCheckmate(context: GameContext): Boolean =
@@ -115,18 +113,18 @@ object DefaultRules extends RuleSet:
// ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── // ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
private def slidingMoves( private def slidingMoves(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color,
dirs: List[(Int, Int)] dirs: List[(Int, Int)],
): List[Move] = ): List[Move] =
dirs.flatMap(dir => castRay(context.board, from, color, dir)) dirs.flatMap(dir => castRay(context.board, from, color, dir))
private def castRay( private def castRay(
board: Board, board: Board,
from: Square, from: Square,
color: Color, color: Color,
dir: (Int, Int) dir: (Int, Int),
): List[Move] = ): List[Move] =
@tailrec @tailrec
def loop(sq: Square, acc: List[Move]): List[Move] = def loop(sq: Square, acc: List[Move]): List[Move] =
@@ -134,40 +132,40 @@ object DefaultRules extends RuleSet:
case None => acc case None => acc
case Some(next) => case Some(next) =>
board.pieceAt(next) match 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(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
case Some(_) => acc case Some(_) => acc
loop(from, Nil).reverse loop(from, Nil).reverse
// ── Knight ───────────────────────────────────────────────────────── // ── Knight ─────────────────────────────────────────────────────────
private def knightCandidates( private def knightCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
KnightJumps.flatMap { (df, dr) => KnightJumps.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to => from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match context.board.pieceAt(to) match
case Some(p) if p.color == color => None case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to)) case None => Some(Move(from, to))
} }
} }
// ── King ─────────────────────────────────────────────────────────── // ── King ───────────────────────────────────────────────────────────
private def kingCandidates( private def kingCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
val steps = QueenDirs.flatMap { (df, dr) => val steps = QueenDirs.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to => from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match context.board.pieceAt(to) match
case Some(p) if p.color == color => None case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to)) case None => Some(Move(from, to))
} }
} }
steps ++ castlingCandidates(context, from, color) steps ++ castlingCandidates(context, from, color)
@@ -175,17 +173,17 @@ object DefaultRules extends RuleSet:
// ── Castling ─────────────────────────────────────────────────────── // ── Castling ───────────────────────────────────────────────────────
private case class CastlingMove( private case class CastlingMove(
kingFromAlg: String, kingFromAlg: String,
kingToAlg: String, kingToAlg: String,
middleAlg: String, middleAlg: String,
rookFromAlg: String, rookFromAlg: String,
moveType: MoveType moveType: MoveType,
) )
private def castlingCandidates( private def castlingCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
color match color match
case Color.White => whiteCastles(context, from) case Color.White => whiteCastles(context, from)
@@ -196,10 +194,18 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty if from != expected then List.empty
else else
val moves = scala.collection.mutable.ListBuffer[Move]() val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(context, moves, context.castlingRights.whiteKingSide, addCastleMove(
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside)) context,
addCastleMove(context, moves, context.castlingRights.whiteQueenSide, moves,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside)) 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 moves.toList
private def blackCastles(context: GameContext, from: Square): List[Move] = private def blackCastles(context: GameContext, from: Square): List[Move] =
@@ -207,10 +213,18 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty if from != expected then List.empty
else else
val moves = scala.collection.mutable.ListBuffer[Move]() val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(context, moves, context.castlingRights.blackKingSide, addCastleMove(
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside)) context,
addCastleMove(context, moves, context.castlingRights.blackQueenSide, moves,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside)) 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 moves.toList
private def queensideBSquare(kingToAlg: String): List[String] = private def queensideBSquare(kingToAlg: String): List[String] =
@@ -220,10 +234,10 @@ object DefaultRules extends RuleSet:
case _ => List.empty case _ => List.empty
private def addCastleMove( private def addCastleMove(
context: GameContext, context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move], moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean, castlingRight: Boolean,
castlingMove: CastlingMove castlingMove: CastlingMove,
): Unit = ): Unit =
if castlingRight then if castlingRight then
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg)) val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
@@ -235,16 +249,15 @@ object DefaultRules extends RuleSet:
kt <- Square.fromAlgebraic(castlingMove.kingToAlg) kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg) rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
do 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 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 rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
val squaresSafe = val squaresSafe =
!isAttackedBy(context.board, kf, color.opposite) && !isAttackedBy(context.board, kf, color.opposite) &&
!isAttackedBy(context.board, km, color.opposite) && !isAttackedBy(context.board, km, color.opposite) &&
!isAttackedBy(context.board, kt, color.opposite) !isAttackedBy(context.board, kt, color.opposite)
if kingPresent && rookPresent && squaresSafe then if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
moves += Move(kf, kt, castlingMove.moveType)
private def squaresEmpty(board: Board, squares: List[Square]): Boolean = private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
squares.forall(sq => board.pieceAt(sq).isEmpty) squares.forall(sq => board.pieceAt(sq).isEmpty)
@@ -252,22 +265,26 @@ object DefaultRules extends RuleSet:
// ── Pawn ─────────────────────────────────────────────────────────── // ── Pawn ───────────────────────────────────────────────────────────
private def pawnCandidates( private def pawnCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
val fwd = pawnForward(color) val fwd = pawnForward(color)
val startRank = pawnStartRank(color) val startRank = pawnStartRank(color)
val promoRank = pawnPromoRank(color) val promoRank = pawnPromoRank(color)
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty) val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
val double = Option.when(from.rank.ordinal == startRank) { val double = Option
from.offset(0, fwd).flatMap { mid => .when(from.rank.ordinal == startRank) {
Option.when(context.board.pieceAt(mid).isEmpty) { from.offset(0, fwd).flatMap { mid =>
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty) Option
}.flatten .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 => val diagonalCaptures = List(-1, 1).flatMap { df =>
from.offset(df, fwd).flatMap { to => from.offset(df, fwd).flatMap { to =>
@@ -286,55 +303,56 @@ object DefaultRules extends RuleSet:
def toMoves(dest: Square, isCapture: Boolean): List[Move] = def toMoves(dest: Square, isCapture: Boolean): List[Move] =
if dest.rank.ordinal == promoRank then if dest.rank.ordinal == promoRank then
List( List(
PromotionPiece.Queen, PromotionPiece.Rook, PromotionPiece.Queen,
PromotionPiece.Bishop, PromotionPiece.Knight PromotionPiece.Rook,
PromotionPiece.Bishop,
PromotionPiece.Knight,
).map(pt => Move(from, dest, MoveType.Promotion(pt))) ).map(pt => Move(from, dest, MoveType.Promotion(pt)))
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture))) else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
val stepSquares = single.toList ++ double.toList val stepSquares = single.toList ++ double.toList
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false)) val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true)) val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
stepMoves ++ captureMoves ++ epCaptures stepMoves ++ captureMoves ++ epCaptures
// ── Check detection ──────────────────────────────────────────────── // ── 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 = private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
attackedBySlider(board, target, attacker, RookDirs, PieceType.Rook) || Square.all.exists { sq =>
attackedBySlider(board, target, attacker, BishopDirs, PieceType.Bishop) || board.pieceAt(sq).fold(false) { p =>
attackedByKnight(board, target, attacker) || p.color == attacker && squareAttacks(board, sq, p, target)
attackedByPawn(board, target, attacker) || }
attackedByKing(board, target, attacker) }
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 => dirs.exists { dir =>
@tailrec def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match @tailrec
case None => false def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
case Some(next) => board.pieceAt(next) match case None => false
case None => loop(next) case Some(next) if next == target => true
case Some(p) if p.color == attacker && (p.pieceType == sliderType || p.pieceType == PieceType.Queen) => true case Some(next) if board.pieceAt(next).isEmpty => loop(next)
case _ => false case Some(_) => false
loop(target) loop(from)
}
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))
} }
private def leavesKingInCheck(context: GameContext, move: Move): Boolean = 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) val nextContext = context.withBoard(nextBoard)
isCheck(nextContext) isCheck(nextContext)
@@ -342,7 +360,7 @@ object DefaultRules extends RuleSet:
override def applyMove(context: GameContext)(move: Move): GameContext = override def applyMove(context: GameContext)(move: Move): GameContext =
val color = context.turn val color = context.turn
val board = context.board val board = context.board
val newBoard = move.moveType match val newBoard = move.moveType match
case MoveType.CastleKingside => applyCastle(board, color, kingside = true) 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.Promotion(pp) => applyPromotion(board, move, color, pp)
case MoveType.Normal(_) => board.applyMove(move) 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 newEnPassantSquare = computeEnPassantSquare(board, move)
val isCapture = move.moveType match val isCapture = move.moveType match
case MoveType.Normal(capture) => capture case MoveType.Normal(capture) => capture
case MoveType.EnPassant => true case MoveType.EnPassant => true
case _ => board.pieceAt(move.to).isDefined case _ => board.pieceAt(move.to).isDefined
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) 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 context
.withBoard(newBoard) .withBoard(newBoard)
@@ -371,19 +389,18 @@ object DefaultRules extends RuleSet:
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board = private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) = val (kingFrom, kingTo, rookFrom, rookTo) =
if kingside then if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
(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))
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 king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook)) val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
board board
.removed(kingFrom).removed(rookFrom) .removed(kingFrom)
.removed(rookFrom)
.updated(kingTo, king) .updated(kingTo, king)
.updated(rookTo, rook) .updated(rookTo, rook)
private def applyEnPassant(board: Board, move: Move): Board = 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) val capturedSquare = Square(move.to.file, capturedRank)
board.applyMove(move).removed(capturedSquare) board.applyMove(move).removed(capturedSquare)
@@ -396,7 +413,7 @@ object DefaultRules extends RuleSet:
board.removed(move.from).updated(move.to, Piece(color, promotedType)) board.removed(move.from).updated(move.to, Piece(color, promotedType))
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights = 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 isKingMove = piece.exists(_.pieceType == PieceType.King)
val isRookMove = piece.exists(_.pieceType == PieceType.Rook) val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
@@ -406,19 +423,25 @@ object DefaultRules extends RuleSet:
val blackKingsideRook = Square(File.H, Rank.R8) val blackKingsideRook = Square(File.H, Rank.R8)
val blackQueensideRook = Square(File.A, Rank.R8) val blackQueensideRook = Square(File.A, Rank.R8)
var r = rights val afterKingMove = if isKingMove then rights.revokeColor(color) else rights
if isKingMove then r = r.revokeColor(color)
else if isRookMove then val afterRookMove =
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White) if !isRookMove then afterKingMove
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White) else
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black) move.from match
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black) 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 // Also revoke if a rook is captured
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White) move.to match
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White) case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White)
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black) case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White)
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black) case `blackKingsideRook` => afterRookMove.revokeKingSide(Color.Black)
r case `blackQueensideRook` => afterRookMove.revokeQueenSide(Color.Black)
case _ => afterRookMove
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] = private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
val piece = board.pieceAt(move.from) val piece = board.pieceAt(move.from)
@@ -435,9 +458,10 @@ object DefaultRules extends RuleSet:
private def insufficientMaterial(board: Board): Boolean = private def insufficientMaterial(board: Board): Boolean =
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King) val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
pieces match pieces match
case Nil => true case Nil => true
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
case List(p1, p2) case List(p1, p2)
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
&& p1.color != p2.color => true && p1.color != p2.color =>
true
case _ => false case _ => false
@@ -29,10 +29,15 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("pawn can capture diagonally"): test("pawn can capture diagonally"):
// FEN: white pawn e4, black pawn d5 // FEN: white pawn e4, black pawn d5
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) 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 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 captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
test("pawn cannot move backward"): test("pawn cannot move backward"):
+5
View File
@@ -38,6 +38,11 @@ tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") 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<JavaExec>("run") { tasks.named<JavaExec>("run") {
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8") jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
standardInput = System.`in` standardInput = System.`in`
@@ -1,6 +1,6 @@
package de.nowchess.ui 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.util.PolyglotBook
import de.nowchess.bot.BotDifficulty import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.bots.{ClassicalBot, HybridBot, NNUEBot} import de.nowchess.bot.bots.{ClassicalBot, HybridBot, NNUEBot}
@@ -16,10 +16,9 @@ object Main:
// Create the core game engine (single source of truth) // Create the core game engine (single source of truth)
val engine = new GameEngine() val engine = new GameEngine()
val book = PolyglotBook("../../modules/bot/codekiddy.bin") 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 // Launch ScalaFX GUI in separate thread
ChessGUILauncher.launch(engine) ChessGUILauncher.launch(engine)
@@ -6,24 +6,26 @@ import de.nowchess.api.board.{Color, Piece, PieceType}
/** Utility object for loading chess piece sprites. */ /** Utility object for loading chess piece sprites. */
object PieceSprites: 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. /** Load a piece sprite image from resources. Sprites are cached for performance.
*/ */
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] = def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image => val image = spriteCache.getOrElseUpdate(key, loadImage(key))
new ImageView(image) {
fitWidth = size new ImageView(image) {
fitHeight = size fitWidth = size
preserveRatio = true fitHeight = size
smooth = true preserveRatio = true
} smooth = true
} }
private def loadImage(key: String): Option[Image] = private def loadImage(key: String): Image =
val path = s"/sprites/pieces/$key.png" 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. */ /** Get square colors for the board using theme. */
object SquareColors: object SquareColors: