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
@@ -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)