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
@@ -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 =
@@ -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]()
@@ -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
@@ -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"):
+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] =
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: