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 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
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/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
View File
@@ -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
@@ -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)
(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
/** 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)) {
@@ -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
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)))
else Some(MoveType.Normal(context.board.pieces.contains(to)))
moveTypeOpt.map(moveType => Move(from, to, moveType))
@@ -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,15 +34,17 @@ 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
@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)
@@ -276,8 +278,7 @@ 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()
else requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
@@ -328,8 +329,7 @@ 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
@@ -358,8 +358,7 @@ class GameEngine(
move.moveType match
case MoveType.Promotion(pp) => completePromotion(pp)
case _ => ()
else
executeMove(legalMove)
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 =
@@ -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]
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get
observer.events.last match
case event: CheckmateEvent =>
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"):
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]()
@@ -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()
@@ -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
@@ -21,20 +22,20 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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()
@@ -70,11 +71,11 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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 _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => ()
engine.subscribe(observer)
@@ -84,7 +85,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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()
@@ -94,11 +95,11 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
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 _: 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)
@@ -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)
@@ -36,7 +36,8 @@ 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
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)
@@ -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 =
@@ -118,7 +116,7 @@ object DefaultRules extends RuleSet:
context: GameContext,
from: Square,
color: Color,
dirs: List[(Int, Int)]
dirs: List[(Int, Int)],
): List[Move] =
dirs.flatMap(dir => castRay(context.board, from, color, dir))
@@ -126,7 +124,7 @@ object DefaultRules extends RuleSet:
board: Board,
from: Square,
color: Color,
dir: (Int, Int)
dir: (Int, Int),
): List[Move] =
@tailrec
def loop(sq: Square, acc: List[Move]): List[Move] =
@@ -144,7 +142,7 @@ object DefaultRules extends RuleSet:
private def knightCandidates(
context: GameContext,
from: Square,
color: Color
color: Color,
): List[Move] =
KnightJumps.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to =>
@@ -160,7 +158,7 @@ object DefaultRules extends RuleSet:
private def kingCandidates(
context: GameContext,
from: Square,
color: Color
color: Color,
): List[Move] =
val steps = QueenDirs.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to =>
@@ -179,13 +177,13 @@ object DefaultRules extends RuleSet:
kingToAlg: String,
middleAlg: String,
rookFromAlg: String,
moveType: MoveType
moveType: MoveType,
)
private def castlingCandidates(
context: GameContext,
from: Square,
color: Color
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] =
@@ -223,7 +237,7 @@ object DefaultRules extends RuleSet:
context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean,
castlingMove: CastlingMove
castlingMove: CastlingMove,
): Unit =
if castlingRight then
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, 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)
@@ -254,20 +267,24 @@ object DefaultRules extends RuleSet:
private def pawnCandidates(
context: GameContext,
from: Square,
color: Color
color: Color,
): List[Move] =
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) {
val double = Option
.when(from.rank.ordinal == startRank) {
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)
}.flatten
}
}.flatten
.flatten
}
}
.flatten
val diagonalCaptures = List(-1, 1).flatMap { df =>
from.offset(df, fwd).flatMap { to =>
@@ -286,8 +303,10 @@ 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)))
@@ -298,39 +317,38 @@ object DefaultRules extends RuleSet:
// ── 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
@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))
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 =
@@ -371,14 +389,13 @@ 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)
@@ -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)
@@ -439,5 +462,6 @@ object DefaultRules extends RuleSet:
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
&& p1.color != p2.color =>
true
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 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 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"):
+5
View File
@@ -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] =
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
new ImageView(image) {
fitWidth = size
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: