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:
@@ -1,21 +1,28 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
|
||||
object Parser:
|
||||
|
||||
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||
* format.
|
||||
/** Parses UCI move notation: "e2e4" (4 chars) or "e7e8q" (5 chars with promotion piece suffix). The promotion suffix
|
||||
* is q=Queen, r=Rook, b=Bishop, n=Knight. Returns None for invalid input.
|
||||
*/
|
||||
def parseMove(input: String): Option[(Square, Square)] =
|
||||
def parseMove(input: String): Option[(Square, Square, Option[PromotionPiece])] =
|
||||
val trimmed = input.trim.toLowerCase
|
||||
Option
|
||||
.when(trimmed.length == 4)(trimmed)
|
||||
.flatMap: s =>
|
||||
trimmed.length match
|
||||
case 4 =>
|
||||
for
|
||||
from <- parseSquare(s.substring(0, 2))
|
||||
to <- parseSquare(s.substring(2, 4))
|
||||
yield (from, to)
|
||||
from <- parseSquare(trimmed.substring(0, 2))
|
||||
to <- parseSquare(trimmed.substring(2, 4))
|
||||
yield (from, to, None)
|
||||
case 5 =>
|
||||
for
|
||||
from <- parseSquare(trimmed.substring(0, 2))
|
||||
to <- parseSquare(trimmed.substring(2, 4))
|
||||
promo <- parsePromotion(trimmed(4))
|
||||
yield (from, to, Some(promo))
|
||||
case _ => None
|
||||
|
||||
private def parseSquare(s: String): Option[Square] =
|
||||
Option
|
||||
@@ -26,3 +33,10 @@ object Parser:
|
||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
||||
)
|
||||
|
||||
private def parsePromotion(c: Char): Option[PromotionPiece] = c match
|
||||
case 'q' => Some(PromotionPiece.Queen)
|
||||
case 'r' => Some(PromotionPiece.Rook)
|
||||
case 'b' => Some(PromotionPiece.Bishop)
|
||||
case 'n' => Some(PromotionPiece.Knight)
|
||||
case _ => None
|
||||
|
||||
@@ -2,7 +2,8 @@ package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
|
||||
import de.nowchess.api.game.{BotParticipant, DrawReason, GameContext, GameResult, Human, Participant}
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import de.nowchess.chess.controller.Parser
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||
@@ -10,29 +11,29 @@ import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||
import de.nowchess.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
class GameEngine(
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules,
|
||||
val participants: Map[Color, Participant] = Map(
|
||||
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
|
||||
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
|
||||
),
|
||||
) extends Observable:
|
||||
// Ensure that initialBoard is set correctly for threefold repetition detection
|
||||
private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
|
||||
initialContext.copy(initialBoard = initialContext.board)
|
||||
else
|
||||
initialContext
|
||||
private val contextWithInitialBoard =
|
||||
if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
|
||||
initialContext.copy(initialBoard = initialContext.board)
|
||||
else initialContext
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var currentContext: GameContext = contextWithInitialBoard
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
@@ -92,11 +93,11 @@ class GameEngine(
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
|
||||
),
|
||||
)
|
||||
case Some((from, to)) =>
|
||||
handleParsedMove(from, to)
|
||||
case Some((from, to, promotionPiece: Option[PromotionPiece])) =>
|
||||
handleParsedMove(from, to, promotionPiece)
|
||||
}
|
||||
|
||||
private def handleParsedMove(from: Square, to: Square): Unit =
|
||||
private def handleParsedMove(from: Square, to: Square, promotionPiece: Option[PromotionPiece]): Unit =
|
||||
currentContext.board.pieceAt(from) match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
|
||||
@@ -109,11 +110,18 @@ class GameEngine(
|
||||
candidates match
|
||||
case Nil =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
|
||||
case moves if isPromotionMove(piece, to) =>
|
||||
// Multiple moves (one per promotion piece) — ask user to choose
|
||||
val contextBefore = currentContext
|
||||
pendingPromotion = Some(PendingPromotion(from, to, contextBefore))
|
||||
notifyObservers(PromotionRequiredEvent(currentContext, from, to))
|
||||
case _ if isPromotionMove(piece, to) =>
|
||||
if promotionPiece.isEmpty then
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(currentContext, "Promotion piece required: append q, r, b, or n to the move."),
|
||||
)
|
||||
else
|
||||
candidates.find(_.moveType == MoveType.Promotion(promotionPiece.get)) match
|
||||
case None =>
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(currentContext, "Error completing promotion: no matching legal move."),
|
||||
)
|
||||
case Some(move) => executeMove(move)
|
||||
case move :: _ =>
|
||||
executeMove(move)
|
||||
|
||||
@@ -123,21 +131,6 @@ class GameEngine(
|
||||
to.rank.ordinal == promoRank
|
||||
}
|
||||
|
||||
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
|
||||
*/
|
||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||
pendingPromotion match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
|
||||
case Some(pending) =>
|
||||
pendingPromotion = None
|
||||
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||
// Verify it's actually legal
|
||||
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||
if legal.contains(move) then executeMove(move)
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||
}
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized(performUndo())
|
||||
|
||||
@@ -159,7 +152,6 @@ class GameEngine(
|
||||
private def replayGame(ctx: GameContext): Either[String, Unit] =
|
||||
val savedContext = currentContext
|
||||
currentContext = GameContext.initial
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
|
||||
if ctx.moves.isEmpty then
|
||||
@@ -175,14 +167,13 @@ class GameEngine(
|
||||
result
|
||||
|
||||
private def applyReplayMove(move: Move): Either[String, Unit] =
|
||||
handleParsedMove(move.from, move.to)
|
||||
move.moveType match
|
||||
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
|
||||
completePromotion(pp)
|
||||
Right(())
|
||||
case MoveType.Promotion(_) =>
|
||||
Left(s"Promotion required for move ${move.from}${move.to}")
|
||||
case _ => Right(())
|
||||
val legal = ruleSet.legalMoves(currentContext)(move.from)
|
||||
val candidate = move.moveType match
|
||||
case MoveType.Promotion(pp) => legal.find(m => m.to == move.to && m.moveType == MoveType.Promotion(pp))
|
||||
case _ => legal.find(_.to == move.to)
|
||||
candidate match
|
||||
case None => Left("Illegal move.")
|
||||
case Some(lm) => executeMove(lm); Right(())
|
||||
|
||||
/** Export the current game context using the provided exporter. */
|
||||
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||
@@ -191,12 +182,10 @@ class GameEngine(
|
||||
|
||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||
val contextWithInitialBoard = if newContext.moves.isEmpty then
|
||||
newContext.copy(initialBoard = newContext.board)
|
||||
else
|
||||
newContext
|
||||
val contextWithInitialBoard =
|
||||
if newContext.moves.isEmpty then newContext.copy(initialBoard = newContext.board)
|
||||
else newContext
|
||||
currentContext = contextWithInitialBoard
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
@@ -208,6 +197,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 =
|
||||
@@ -250,7 +242,9 @@ class GameEngine(
|
||||
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||
|
||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
if ruleSet.isThreefoldRepetition(currentContext) then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
|
||||
if ruleSet.isThreefoldRepetition(currentContext) then
|
||||
notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
|
||||
else requestBotMoveIfNeeded()
|
||||
|
||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
@@ -301,6 +295,47 @@ 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 =
|
||||
val pendingBotMove = synchronized {
|
||||
participants.get(currentContext.turn) match
|
||||
case Some(BotParticipant(bot)) => Some((bot, currentContext))
|
||||
case _ => None
|
||||
}
|
||||
|
||||
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 {
|
||||
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 =
|
||||
synchronized {
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||
}
|
||||
|
||||
private def performUndo(): Unit =
|
||||
if invoker.canUndo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Color, Square}
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||
|
||||
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||
@@ -39,13 +39,6 @@ case class InvalidMoveEvent(
|
||||
reason: String,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||
case class PromotionRequiredEvent(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
context: GameContext,
|
||||
|
||||
Reference in New Issue
Block a user