feat: Improved how NNUE Evalutes
This commit is contained in:
@@ -10,7 +10,7 @@ import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||
import de.nowchess.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import de.nowchess.bot.Bot
|
||||
import scala.concurrent.{Future, ExecutionContext}
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
@@ -34,16 +34,18 @@ class GameEngine(
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** Optional opponent bot and the color it plays. */
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var opponentBot: Option[Bot] = None
|
||||
private var opponentColor: Option[Color] = None
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var opponentColor: Option[Color] = None
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||
|
||||
/** Set an opponent bot to play against.
|
||||
* The bot will play as the given color and auto-play moves after the opponent moves.
|
||||
*/
|
||||
/** Set an opponent bot to play against. The bot will play as the given color and auto-play moves after the opponent
|
||||
* moves.
|
||||
*/
|
||||
def setOpponentBot(bot: Bot, color: Color): Unit = synchronized {
|
||||
opponentBot = Some(bot)
|
||||
opponentColor = Some(color)
|
||||
@@ -275,9 +277,8 @@ class GameEngine(
|
||||
|
||||
// Request bot move if it's the opponent bot's turn
|
||||
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
|
||||
() // Game is over, don't request bot move
|
||||
else
|
||||
requestBotMoveIfNeeded()
|
||||
() // Game is over, don't request bot move
|
||||
else requestBotMoveIfNeeded()
|
||||
|
||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
@@ -328,24 +329,23 @@ class GameEngine(
|
||||
case _ =>
|
||||
context.board.pieceAt(move.to)
|
||||
|
||||
/** Request a move from the opponent bot if it's their turn.
|
||||
* Spawns an async task to avoid blocking the engine.
|
||||
*/
|
||||
/** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
|
||||
*/
|
||||
private def requestBotMoveIfNeeded(): Unit =
|
||||
(opponentBot, opponentColor) match
|
||||
case (Some(bot), Some(color)) if currentContext.turn == color =>
|
||||
Future {
|
||||
bot.nextMove(currentContext) match
|
||||
case Some(move) => applyBotMove(move, color)
|
||||
case None => handleBotNoMove()
|
||||
case None => handleBotNoMove()
|
||||
}
|
||||
case _ => () // No bot or not bot's turn
|
||||
case _ => () // No bot or not bot's turn
|
||||
|
||||
private def applyBotMove(move: Move, color: Color): Unit =
|
||||
synchronized {
|
||||
if currentContext.turn == color then
|
||||
val from = move.from
|
||||
val to = move.to
|
||||
val to = move.to
|
||||
currentContext.board.pieceAt(from) match
|
||||
case Some(piece) if piece.color == color =>
|
||||
val legal = ruleSet.legalMoves(currentContext)(from)
|
||||
@@ -353,13 +353,12 @@ class GameEngine(
|
||||
case Some(legalMove) =>
|
||||
val isPromotion = move.moveType match
|
||||
case MoveType.Promotion(_) => true
|
||||
case _ => false
|
||||
case _ => false
|
||||
if isPromotion then
|
||||
move.moveType match
|
||||
case MoveType.Promotion(pp) => completePromotion(pp)
|
||||
case _ => ()
|
||||
else
|
||||
executeMove(legalMove)
|
||||
case _ => ()
|
||||
else executeMove(legalMove)
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal"))
|
||||
case _ =>
|
||||
@@ -371,8 +370,7 @@ class GameEngine(
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||
else if ruleSet.isStalemate(currentContext) then
|
||||
notifyObservers(StalemateEvent(currentContext))
|
||||
else if ruleSet.isStalemate(currentContext) then notifyObservers(StalemateEvent(currentContext))
|
||||
}
|
||||
|
||||
private def performUndo(): Unit =
|
||||
|
||||
+16
-9
@@ -1,9 +1,8 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.DrawReason
|
||||
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -24,10 +23,15 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
||||
observer.events.last shouldBe a[CheckmateEvent]
|
||||
observer.events.last match
|
||||
case event: CheckmateEvent =>
|
||||
event.winner shouldBe Color.Black
|
||||
case other =>
|
||||
fail(s"Expected CheckmateEvent, but got $other")
|
||||
|
||||
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get
|
||||
event.winner shouldBe Color.Black
|
||||
// Board should be reset after checkmate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine handles check detection"):
|
||||
val engine = new GameEngine()
|
||||
@@ -83,9 +87,12 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
observer.events.clear()
|
||||
engine.processUserInput(moves.last)
|
||||
|
||||
val drawEvents = observer.events.collect { case e: DrawEvent => e }
|
||||
drawEvents.size shouldBe 1
|
||||
drawEvents.head.reason shouldBe DrawReason.Stalemate
|
||||
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
||||
stalemateEvents.size shouldBe 1
|
||||
|
||||
// Board should be reset after stalemate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
private class EndingMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
+8
-2
@@ -38,7 +38,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
events.count { case _: InvalidMoveEvent => true; case _ => false } should be >= 3
|
||||
events.count {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
} should be >= 3
|
||||
|
||||
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
|
||||
val engine = new GameEngine()
|
||||
@@ -69,7 +72,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.context shouldBe target
|
||||
engine.commandHistory shouldBe empty
|
||||
events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true
|
||||
events.lastOption.exists {
|
||||
case _: de.nowchess.chess.observer.BoardResetEvent => true
|
||||
case _ => false
|
||||
} shouldBe true
|
||||
|
||||
test("redo event includes captured piece description when replaying a capture"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
@@ -43,7 +43,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White castles queenside: e1c1
|
||||
engine.processUserInput("e1c1")
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -68,7 +71,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White pawn on e5 captures en passant to d6
|
||||
engine.processUserInput("e5d6")
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
|
||||
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||
@@ -84,8 +90,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
||||
|
||||
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
|
||||
// Extra white pawn on h2 ensures K+B+P vs K — sufficient material, so draw is not triggered
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -106,8 +111,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── King normal move notation (line 246) ───────────────────────────
|
||||
|
||||
test("undo after king move emits MoveUndoneEvent with K notation"):
|
||||
// White king on e1, white rook on h1 — K+R vs K ensures sufficient material after the king move
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get
|
||||
// White king on e1, no castling rights, black king far away
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -118,7 +123,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// King moves e1 -> f1
|
||||
engine.processUserInput("e1f1")
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
+44
-11
@@ -29,7 +29,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
events.exists { case _: PromotionRequiredEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: PromotionRequiredEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||
}
|
||||
|
||||
@@ -60,7 +63,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with rook underpromotion") {
|
||||
@@ -80,7 +86,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
@@ -92,7 +101,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||
@@ -105,8 +117,14 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.collect { case e: MoveExecutedEvent => e } should not be empty
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
|
||||
events.filter {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should not be empty
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
case _ => false
|
||||
} should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||
@@ -118,7 +136,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: CheckmateEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: CheckmateEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||
@@ -130,7 +151,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: DrawEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: StalemateEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with black pawn promotion results in Moved") {
|
||||
@@ -143,8 +167,14 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.collect { case e: MoveExecutedEvent => e } should not be empty
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
|
||||
events.filter {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should not be empty
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
case _ => false
|
||||
} should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||
@@ -193,7 +223,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||
invalidEvt.reason should include("Error completing promotion")
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package de.nowchess.chess.engine
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.bot.bots.ClassicalBot
|
||||
import de.nowchess.bot.{BotDifficulty, BotController}
|
||||
import de.nowchess.bot.{BotController, BotDifficulty}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
|
||||
import scala.concurrent.duration.*
|
||||
import scala.concurrent.Await
|
||||
|
||||
@@ -15,26 +16,26 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine can play against a ClassicalBot"):
|
||||
val engine = GameEngine(GameContext.initial, DefaultRules)
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
|
||||
// Set White (human) vs Black (bot)
|
||||
engine.setOpponentBot(bot, Color.Black)
|
||||
|
||||
// Collect events
|
||||
var moveCount = 0
|
||||
var checkmateDetected = false
|
||||
var gameEnded = false
|
||||
val moveCount = new AtomicInteger(0)
|
||||
val checkmateDetected = new AtomicBoolean(false)
|
||||
val gameEnded = new AtomicBoolean(false)
|
||||
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent =>
|
||||
moveCount += 1
|
||||
moveCount.incrementAndGet()
|
||||
case _: CheckmateEvent =>
|
||||
checkmateDetected = true
|
||||
gameEnded = true
|
||||
checkmateDetected.set(true)
|
||||
gameEnded.set(true)
|
||||
case _: StalemateEvent =>
|
||||
gameEnded = true
|
||||
gameEnded.set(true)
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
@@ -46,7 +47,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
Thread.sleep(5000)
|
||||
|
||||
// White should have moved, then Black (bot) should have responded
|
||||
moveCount should be >= 2
|
||||
moveCount.get() should be >= 2
|
||||
|
||||
engine.clearOpponentBot()
|
||||
|
||||
@@ -64,42 +65,42 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
BotController.getBot("unknown") should be(None)
|
||||
|
||||
test("GameEngine handles bot with different difficulty"):
|
||||
val engine = GameEngine(GameContext.initial, DefaultRules)
|
||||
val engine = GameEngine(GameContext.initial, DefaultRules)
|
||||
val hardBot = BotController.getBot("hard").get
|
||||
|
||||
engine.setOpponentBot(hardBot, Color.Black)
|
||||
engine.turn should equal(Color.White)
|
||||
|
||||
var movesMade = 0
|
||||
val movesMade = new AtomicInteger(0)
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent => movesMade += 1
|
||||
case _ => ()
|
||||
case _: MoveExecutedEvent => movesMade.incrementAndGet()
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// White moves
|
||||
engine.processUserInput("d2d4")
|
||||
Thread.sleep(500) // Wait for bot response
|
||||
Thread.sleep(500) // Wait for bot response
|
||||
|
||||
// At least white moved, possibly black also responded
|
||||
movesMade should be >= 1
|
||||
movesMade.get() should be >= 1
|
||||
|
||||
engine.clearOpponentBot()
|
||||
|
||||
test("GameEngine plays valid bot moves"):
|
||||
val engine = GameEngine(GameContext.initial, DefaultRules)
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
|
||||
engine.setOpponentBot(bot, Color.Black)
|
||||
|
||||
var moveCount = 0
|
||||
val moveCount = new AtomicInteger(0)
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent => moveCount += 1
|
||||
case _ => ()
|
||||
case _: MoveExecutedEvent => moveCount.incrementAndGet()
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -108,7 +109,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
Thread.sleep(1000)
|
||||
|
||||
// The game should have progressed with at least one move
|
||||
moveCount should be >= 1
|
||||
moveCount.get() should be >= 1
|
||||
// Game should not be ended (checkmate/stalemate)
|
||||
engine.context.moves.nonEmpty should be(true)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user