feat: Introduce Participant trait and update GameEngine to support bot participants
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": 1,
|
||||
"created": "2026-04-13T19:58:38.629943",
|
||||
"total_positions": 2522562,
|
||||
"stockfish_depth": 12,
|
||||
"sources": [
|
||||
{
|
||||
"type": "legacy_import",
|
||||
"path": "data/training_data.jsonl",
|
||||
"count": 2009355,
|
||||
"note": "Migrated from data/training_data.jsonl"
|
||||
},
|
||||
{
|
||||
"type": "test_extend",
|
||||
"count": 4,
|
||||
"actual_count": 3
|
||||
},
|
||||
{
|
||||
"type": "test_new_positions",
|
||||
"count": 3,
|
||||
"actual_count": 3
|
||||
},
|
||||
{
|
||||
"type": "test_mixed",
|
||||
"count": 5,
|
||||
"actual_count": 0
|
||||
},
|
||||
{
|
||||
"type": "test_all_dups",
|
||||
"count": 2,
|
||||
"actual_count": 0
|
||||
},
|
||||
{
|
||||
"type": "guaranteed_unique",
|
||||
"count": 10,
|
||||
"actual_count": 8
|
||||
},
|
||||
{
|
||||
"type": "merged_sources",
|
||||
"count": 600000,
|
||||
"sources": [
|
||||
{
|
||||
"type": "tactical",
|
||||
"count": 600000,
|
||||
"max_puzzles": 600000
|
||||
}
|
||||
],
|
||||
"actual_count": 599993
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package de.nowchess.ui
|
||||
|
||||
import de.nowchess.api.board.Color.{Black, White}
|
||||
import de.nowchess.bot.util.PolyglotBook
|
||||
import de.nowchess.bot.BotDifficulty
|
||||
import de.nowchess.bot.bots.{ClassicalBot, HybridBot, NNUEBot}
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.ui.terminal.TerminalUI
|
||||
import de.nowchess.ui.gui.ChessGUILauncher
|
||||
|
||||
@@ -13,12 +10,21 @@ import de.nowchess.ui.gui.ChessGUILauncher
|
||||
*/
|
||||
object Main:
|
||||
def main(args: Array[String]): Unit =
|
||||
// Create the core game engine (single source of truth)
|
||||
val engine = new GameEngine()
|
||||
|
||||
val book = PolyglotBook("../../modules/bot/codekiddy.bin")
|
||||
|
||||
engine.setOpponentBot(HybridBot(BotDifficulty.Easy, book = Some(book)), White);
|
||||
// Create the core game engine (single source of truth)
|
||||
val engine = new de.nowchess.chess.engine.GameEngine(
|
||||
participants = Map(
|
||||
de.nowchess.api.board.Color.White -> de.nowchess.chess.engine.BotParticipant(
|
||||
de.nowchess.bot.bots.HybridBot(BotDifficulty.Easy, book = Some(book)),
|
||||
),
|
||||
de.nowchess.api.board.Color.Black -> de.nowchess.chess.engine.BotParticipant(
|
||||
de.nowchess.bot.bots.HybridBot(BotDifficulty.Easy, book = Some(book)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
engine.startGame()
|
||||
|
||||
// Launch ScalaFX GUI in separate thread
|
||||
ChessGUILauncher.launch(engine)
|
||||
|
||||
Reference in New Issue
Block a user