feat: implement bot functionality with difficulty levels and integrate Polyglot opening book
This commit is contained in:
@@ -9,6 +9,8 @@ import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||
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}
|
||||
|
||||
/** 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.
|
||||
@@ -31,9 +33,28 @@ class GameEngine(
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** Optional opponent bot and the color it plays. */
|
||||
private var opponentBot: Option[Bot] = None
|
||||
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.
|
||||
*/
|
||||
def setOpponentBot(bot: Bot, color: Color): Unit = synchronized {
|
||||
opponentBot = Some(bot)
|
||||
opponentColor = Some(color)
|
||||
}
|
||||
|
||||
/** Clear the opponent bot. */
|
||||
def clearOpponentBot(): Unit = synchronized {
|
||||
opponentBot = None
|
||||
opponentColor = None
|
||||
}
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
@@ -252,6 +273,12 @@ class GameEngine(
|
||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
|
||||
|
||||
// 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()
|
||||
|
||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
@@ -301,6 +328,53 @@ 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.
|
||||
*/
|
||||
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 _ => () // 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
|
||||
currentContext.board.pieceAt(from) match
|
||||
case Some(piece) if piece.color == color =>
|
||||
val legal = ruleSet.legalMoves(currentContext)(from)
|
||||
legal.find(m => m.to == to && m.moveType == move.moveType) match
|
||||
case Some(legalMove) =>
|
||||
val isPromotion = move.moveType match
|
||||
case MoveType.Promotion(_) => true
|
||||
case _ => false
|
||||
if isPromotion then
|
||||
move.moveType match
|
||||
case MoveType.Promotion(pp) => completePromotion(pp)
|
||||
case _ => ()
|
||||
else
|
||||
executeMove(legalMove)
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal"))
|
||||
case _ =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Bot move has invalid source square"))
|
||||
}
|
||||
|
||||
private def handleBotNoMove(): Unit =
|
||||
synchronized {
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||
else if ruleSet.isStalemate(currentContext) then
|
||||
notifyObservers(StalemateEvent(currentContext))
|
||||
}
|
||||
|
||||
private def performUndo(): Unit =
|
||||
if invoker.canUndo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.bot.{BotDifficulty, ClassicalBot, BotController}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.concurrent.duration.*
|
||||
import scala.concurrent.Await
|
||||
|
||||
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine can play against a ClassicalBot"):
|
||||
val engine = GameEngine(GameContext.initial, DefaultRules)
|
||||
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 observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent =>
|
||||
moveCount += 1
|
||||
case _: CheckmateEvent =>
|
||||
checkmateDetected = true
|
||||
gameEnded = true
|
||||
case _: StalemateEvent =>
|
||||
gameEnded = true
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Play a few moves: e2e4, then let the bot respond
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
// Wait a bit for the bot to respond asynchronously
|
||||
Thread.sleep(5000)
|
||||
|
||||
// White should have moved, then Black (bot) should have responded
|
||||
moveCount should be >= 2
|
||||
|
||||
engine.clearOpponentBot()
|
||||
|
||||
test("BotController can list and retrieve bots"):
|
||||
val bots = BotController.listBots
|
||||
bots should contain("easy")
|
||||
bots should contain("medium")
|
||||
bots should contain("hard")
|
||||
bots should contain("expert")
|
||||
|
||||
BotController.getBot("easy") should not be None
|
||||
BotController.getBot("medium") should not be None
|
||||
BotController.getBot("hard") should not be None
|
||||
BotController.getBot("expert") should not be None
|
||||
BotController.getBot("unknown") should be(None)
|
||||
|
||||
test("GameEngine handles bot with different difficulty"):
|
||||
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 observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent => movesMade += 1
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// White moves
|
||||
engine.processUserInput("d2d4")
|
||||
Thread.sleep(500) // Wait for bot response
|
||||
|
||||
// At least white moved, possibly black also responded
|
||||
movesMade should be >= 1
|
||||
|
||||
engine.clearOpponentBot()
|
||||
|
||||
test("GameEngine plays valid bot moves"):
|
||||
val engine = GameEngine(GameContext.initial, DefaultRules)
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
|
||||
engine.setOpponentBot(bot, Color.Black)
|
||||
|
||||
var moveCount = 0
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent => moveCount += 1
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Play a normal move
|
||||
engine.processUserInput("e2e4")
|
||||
Thread.sleep(1000)
|
||||
|
||||
// The game should have progressed with at least one move
|
||||
moveCount should be >= 1
|
||||
// Game should not be ended (checkmate/stalemate)
|
||||
engine.context.moves.nonEmpty should be(true)
|
||||
|
||||
engine.clearOpponentBot()
|
||||
Reference in New Issue
Block a user