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
@@ -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 (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)
(move.orElse(bestSoFar), score) (move.orElse(bestSoFar), score)
}._1 }
._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)) {
@@ -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] =
if promotionBits > 0 then
promotionBits match promotionBits match
case 1 => Some(MoveType.Promotion(PromotionPiece.Knight)) case 1 => Some(MoveType.Promotion(PromotionPiece.Knight))
case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop)) case 2 => Some(MoveType.Promotion(PromotionPiece.Bishop))
case 3 => Some(MoveType.Promotion(PromotionPiece.Rook)) case 3 => Some(MoveType.Promotion(PromotionPiece.Rook))
case 4 => Some(MoveType.Promotion(PromotionPiece.Queen)) case 4 => Some(MoveType.Promotion(PromotionPiece.Queen))
case _ => None case _ => None
else else Some(MoveType.Normal(context.board.pieces.contains(to)))
Some(MoveType.Normal(context.board.pieces.contains(to)))
moveTypeOpt.map(moveType => Move(from, to, moveType)) moveTypeOpt.map(moveType => Move(from, to, moveType))
@@ -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,15 +34,17 @@ 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
@SuppressWarnings(Array("DisableSyntax.var"))
private var opponentColor: Option[Color] = None 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)
@@ -276,8 +278,7 @@ 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,8 +329,7 @@ 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
@@ -358,8 +358,7 @@ class GameEngine(
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 =>
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get
event.winner shouldBe Color.Black event.winner shouldBe Color.Black
case other =>
fail(s"Expected CheckmateEvent, but got $other")
// Board should be reset after checkmate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine handles check detection"): 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
@@ -21,20 +22,20 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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()
@@ -70,11 +71,11 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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)
@@ -84,7 +85,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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()
@@ -94,11 +95,11 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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)
@@ -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)
@@ -36,7 +36,8 @@ 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
piece.pieceType match
case PieceType.Pawn => pawnCandidates(context, square, piece.color) case PieceType.Pawn => pawnCandidates(context, square, piece.color)
case PieceType.Knight => knightCandidates(context, square, piece.color) case PieceType.Knight => knightCandidates(context, square, piece.color)
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
@@ -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 =
@@ -118,7 +116,7 @@ object DefaultRules extends RuleSet:
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))
@@ -126,7 +124,7 @@ object DefaultRules extends RuleSet:
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] =
@@ -144,7 +142,7 @@ object DefaultRules extends RuleSet:
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 =>
@@ -160,7 +158,7 @@ object DefaultRules extends RuleSet:
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 =>
@@ -179,13 +177,13 @@ object DefaultRules extends RuleSet:
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] =
@@ -223,7 +237,7 @@ object DefaultRules extends RuleSet:
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))
@@ -243,8 +257,7 @@ object DefaultRules extends RuleSet:
!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)
@@ -254,20 +267,24 @@ object DefaultRules extends RuleSet:
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
.when(from.rank.ordinal == startRank) {
from.offset(0, fwd).flatMap { mid => from.offset(0, fwd).flatMap { mid =>
Option.when(context.board.pieceAt(mid).isEmpty) { Option
.when(context.board.pieceAt(mid).isEmpty) {
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty) from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
}.flatten
} }
}.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,8 +303,10 @@ 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)))
@@ -298,39 +317,38 @@ object DefaultRules extends RuleSet:
// ── 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
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
case None => false case None => false
case Some(next) => board.pieceAt(next) match case Some(next) if next == target => true
case None => loop(next) case Some(next) if board.pieceAt(next).isEmpty => loop(next)
case Some(p) if p.color == attacker && (p.pieceType == sliderType || p.pieceType == PieceType.Queen) => true case Some(_) => false
case _ => false loop(from)
loop(target)
}
private def attackedByKnight(board: Board, target: Square, attacker: Color): Boolean =
KnightJumps.exists { (df, dr) =>
target.offset(df, dr).exists(sq => board.pieceAt(sq).exists(p => p.color == attacker && p.pieceType == PieceType.Knight))
}
private def attackedByPawn(board: Board, target: Square, attacker: Color): Boolean =
val dr = if attacker == Color.White then -1 else 1
List(-1, 1).exists { df =>
target.offset(df, dr).exists(sq => board.pieceAt(sq).exists(p => p.color == attacker && p.pieceType == PieceType.Pawn))
}
private def attackedByKing(board: Board, target: Square, attacker: Color): Boolean =
QueenDirs.exists { (df, dr) =>
target.offset(df, dr).exists(sq => board.pieceAt(sq).exists(p => p.color == attacker && p.pieceType == PieceType.King))
} }
private def leavesKingInCheck(context: GameContext, move: Move): Boolean = private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
@@ -371,14 +389,13 @@ 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)
@@ -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)
@@ -439,5 +462,6 @@ object DefaultRules extends RuleSet:
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
@@ -32,7 +32,12 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
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) { new ImageView(image) {
fitWidth = size fitWidth = size
fitHeight = size fitHeight = size
preserveRatio = true preserveRatio = true
smooth = 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: