feat: NCS-41 Bot Platform (#33)

Co-authored-by: Janis <janis@nowchess.de>
Reviewed-on: #33
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit is contained in:
2026-04-19 15:52:08 +02:00
committed by Janis
parent 5f4d33f3ca
commit 8744bee2dd
115 changed files with 8573 additions and 424 deletions
+2 -5
View File
@@ -23,11 +23,7 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(listOf(
"de.nowchess.ui.gui",
"de.nowchess.ui.terminal",
"de.nowchess.ui.Main",
))
excludedPackages.set(listOf("de\\.nowchess\\.ui\\..*"))
}
application {
@@ -64,6 +60,7 @@ dependencies {
implementation(project(":modules:rule"))
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:bot"))
// ScalaFX dependencies
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
@@ -1,6 +1,9 @@
package de.nowchess.ui
import de.nowchess.chess.engine.GameEngine
import de.nowchess.api.game.{BotParticipant, Human}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.BotDifficulty
import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher
@@ -9,8 +12,19 @@ import de.nowchess.ui.gui.ChessGUILauncher
*/
object Main:
def main(args: Array[String]): Unit =
val book = PolyglotBook("../../modules/bot/codekiddy.bin")
// Create the core game engine (single source of truth)
val engine = new GameEngine()
val engine = new de.nowchess.chess.engine.GameEngine(
participants = Map(
de.nowchess.api.board.Color.White -> BotParticipant(
de.nowchess.bot.bots.HybridBot(BotDifficulty.Easy, book = Some(book)),
),
de.nowchess.api.board.Color.Black -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
),
)
engine.startGame()
// Launch ScalaFX GUI in separate thread
ChessGUILauncher.launch(engine)
@@ -11,7 +11,7 @@ import scalafx.scene.shape.Rectangle
import scalafx.scene.text.{Font, Text}
import scalafx.stage.Stage
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.move.MoveType
import de.nowchess.chess.command.{MoveCommand, MoveResult}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.io.fen.{FenExporter, FenParser}
@@ -178,36 +178,42 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
square
private def handleSquareClick(rank: Int, file: Int): Unit =
if !engine.isPendingPromotion then
val clickedSquare = Square(File.values(file), Rank.values(rank))
val clickedSquare = Square(File.values(file), Rank.values(rank))
selectedSquare.get() match
case None =>
// First click - select piece if it belongs to current player
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
if piece.color == currentTurn.get() then
selectedSquare.set(Some(clickedSquare))
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
selectedSquare.get() match
case None =>
// First click - select piece if it belongs to current player
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
if piece.color == currentTurn.get() then
selectedSquare.set(Some(clickedSquare))
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet
.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
val legalDests = engine.ruleSet
.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
case Some(fromSquare) =>
// Second click - attempt move
if clickedSquare == fromSquare then
// Deselect
selectedSquare.set(None)
updateBoard(currentBoard.get(), currentTurn.get())
else
// Try to move
val moveStr = s"${fromSquare}$clickedSquare"
engine.processUserInput(moveStr)
selectedSquare.set(None)
case Some(fromSquare) =>
// Second click - attempt move
if clickedSquare == fromSquare then
// Deselect
selectedSquare.set(None)
updateBoard(currentBoard.get(), currentTurn.get())
else
val isPromo = engine.ruleSet
.legalMoves(engine.context)(fromSquare)
.exists(m =>
m.to == clickedSquare && (m.moveType match
case MoveType.Promotion(_) => true
case _ => false
),
)
if isPromo then showPromotionDialog(fromSquare, clickedSquare)
else engine.processUserInput(s"${fromSquare}$clickedSquare")
selectedSquare.set(None)
def updateBoard(board: Board, turn: Color): Unit =
currentBoard.set(board)
@@ -266,7 +272,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case Some(piece) =>
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
case None =>
Seq(bgRect)): Seq[scalafx.scene.Node]
Seq(bgRect)
): Seq[scalafx.scene.Node]
}
def showMessage(msg: String): Unit =
@@ -280,14 +287,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
headerText = "Choose promotion piece"
contentText = "Promote to:"
}
val result = dialog.showAndWait()
result match
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
val uciSuffix = dialog.showAndWait() match
case Some("Rook") => "r"
case Some("Bishop") => "b"
case Some("Knight") => "n"
case _ => "q"
engine.processUserInput(s"${from}${to}$uciSuffix")
private def doFenExport(): Unit =
doExport(FenExporter, "FEN")
@@ -30,9 +30,9 @@ class ChessGUIApp extends JFXApplication:
stage.scene = new Scene {
root = boardView
// Load CSS if available
try {
try
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
} catch {
catch {
case _: Exception => // CSS is optional
}
}
@@ -47,9 +47,6 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage("Board has been reset to initial position.")
case e: PromotionRequiredEvent =>
boardView.showPromotionDialog(e.from, e.to)
case e: FiftyMoveRuleAvailableEvent =>
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
@@ -2,7 +2,6 @@ package de.nowchess.ui.terminal
import java.util.concurrent.atomic.AtomicBoolean
import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
@@ -12,8 +11,7 @@ import de.nowchess.ui.utils.Renderer
* I/O and user interaction in the terminal.
*/
class TerminalUI(engine: GameEngine) extends Observer:
private val running = new AtomicBoolean(true)
private val awaitingPromotion = new AtomicBoolean(false)
private val running = new AtomicBoolean(true)
/** Called by GameEngine whenever a game event occurs. */
override def onGameEvent(event: GameEvent): Unit =
@@ -65,9 +63,6 @@ class TerminalUI(engine: GameEngine) extends Observer:
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case _: PromotionRequiredEvent =>
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
awaitingPromotion.set(true)
case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule is now available — type 'draw' to claim.")
@@ -90,28 +85,17 @@ class TerminalUI(engine: GameEngine) extends Observer:
print(Renderer.render(engine.board))
printPrompt(engine.turn)
// Game loop
while running.get() do
val input = Option(StdIn.readLine()).getOrElse("quit").trim
synchronized {
if awaitingPromotion.get() then
input.toLowerCase match
case "q" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Queen)
case "r" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Rook)
case "b" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Bishop)
case "n" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Knight)
case _ =>
println("Invalid choice. Enter q, r, b, or n.")
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
else
input.toLowerCase match
case "quit" | "q" =>
running.set(false)
println("Game over. Goodbye!")
case "" =>
printPrompt(engine.turn)
case _ =>
engine.processUserInput(input)
input.toLowerCase match
case "quit" | "q" =>
running.set(false)
println("Game over. Goodbye!")
case "" =>
printPrompt(engine.turn)
case _ =>
engine.processUserInput(input)
}
// Unsubscribe when done