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