feat: Introduce Participant trait and update GameEngine to support bot participants

This commit is contained in:
2026-04-13 22:11:54 +02:00
parent c88159ecec
commit d6758ed8ec
25 changed files with 2526369 additions and 1672 deletions
@@ -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()