feat: Introduce Participant trait and update GameEngine to support bot participants
This commit is contained in:
@@ -9,7 +9,6 @@ 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.{ExecutionContext, Future}
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||
@@ -18,6 +17,7 @@ import scala.concurrent.{ExecutionContext, Future}
|
||||
class GameEngine(
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules,
|
||||
val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human),
|
||||
) extends Observable:
|
||||
// Ensure that initialBoard is set correctly for threefold repetition detection
|
||||
private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
|
||||
@@ -33,30 +33,11 @@ class GameEngine(
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** Optional opponent bot and the color it plays. */
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var opponentBot: Option[Bot] = None
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var opponentColor: Option[Color] = None
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||
|
||||
/** Set an opponent bot to play against. The bot will play as the given color and auto-play moves after the opponent
|
||||
* moves.
|
||||
*/
|
||||
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)
|
||||
@@ -231,6 +212,9 @@ class GameEngine(
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
|
||||
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
|
||||
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
|
||||
|
||||
// ──── Private helpers ────
|
||||
|
||||
private def executeMove(move: Move): Unit =
|
||||
@@ -332,37 +316,34 @@ class GameEngine(
|
||||
/** 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
|
||||
val pendingBotMove = synchronized {
|
||||
participants.get(currentContext.turn) match
|
||||
case Some(BotParticipant(bot)) => Some((bot, currentContext))
|
||||
case _ => None
|
||||
}
|
||||
|
||||
private def applyBotMove(move: Move, color: Color): Unit =
|
||||
pendingBotMove.foreach { case (bot, contextAtRequest) =>
|
||||
Future {
|
||||
bot.nextMove(contextAtRequest) match
|
||||
case Some(move) => applyBotMove(move)
|
||||
case None => handleBotNoMove()
|
||||
}
|
||||
}
|
||||
|
||||
private def applyBotMove(move: Move): 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"))
|
||||
val color = currentContext.turn
|
||||
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) => 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 =
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.bot.Bot
|
||||
|
||||
sealed trait Participant
|
||||
case object Human extends Participant
|
||||
final case class BotParticipant(bot: Bot) extends Participant
|
||||
|
||||
@@ -9,17 +9,16 @@ 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
|
||||
|
||||
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)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human, Color.Black -> BotParticipant(bot)),
|
||||
)
|
||||
|
||||
// Collect events
|
||||
val moveCount = new AtomicInteger(0)
|
||||
@@ -49,8 +48,6 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
// White should have moved, then Black (bot) should have responded
|
||||
moveCount.get() should be >= 2
|
||||
|
||||
engine.clearOpponentBot()
|
||||
|
||||
test("BotController can list and retrieve bots"):
|
||||
val bots = BotController.listBots
|
||||
bots should contain("easy")
|
||||
@@ -65,10 +62,12 @@ 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 hardBot = BotController.getBot("hard").get
|
||||
|
||||
engine.setOpponentBot(hardBot, Color.Black)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human, Color.Black -> BotParticipant(hardBot)),
|
||||
)
|
||||
engine.turn should equal(Color.White)
|
||||
|
||||
val movesMade = new AtomicInteger(0)
|
||||
@@ -87,13 +86,13 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
// At least white moved, possibly black also responded
|
||||
movesMade.get() 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)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human, Color.Black -> BotParticipant(bot)),
|
||||
)
|
||||
|
||||
val moveCount = new AtomicInteger(0)
|
||||
val observer = new Observer:
|
||||
@@ -113,4 +112,3 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
// Game should not be ended (checkmate/stalemate)
|
||||
engine.context.moves.nonEmpty should be(true)
|
||||
|
||||
engine.clearOpponentBot()
|
||||
|
||||
Reference in New Issue
Block a user