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,
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
initialShouldFailOnUndo: Boolean = false,
|
||||
initialShouldFailOnExecute: Boolean = false,
|
||||
) extends Command:
|
||||
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
|
||||
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
|
||||
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
|
||||
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
|
||||
override def execute(): Boolean = !shouldFailOnExecute.get()
|
||||
override def undo(): Boolean = !shouldFailOnUndo.get()
|
||||
override def description: String = "Conditional fail"
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class ParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseMove parses valid 'e2e4'"):
|
||||
Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
|
||||
test("parseMove is case-insensitive"):
|
||||
Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
|
||||
test("parseMove trims leading and trailing whitespace"):
|
||||
Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
|
||||
test("parseMove handles corner squares a1h8"):
|
||||
Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8)))
|
||||
Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8), None))
|
||||
|
||||
test("parseMove handles corner squares h8a1"):
|
||||
Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1)))
|
||||
Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1), None))
|
||||
|
||||
test("parseMove returns None for empty string"):
|
||||
Parser.parseMove("") shouldBe None
|
||||
@@ -27,8 +28,8 @@ class ParserTest extends AnyFunSuite with Matchers:
|
||||
test("parseMove returns None for input shorter than 4 chars"):
|
||||
Parser.parseMove("e2e") shouldBe None
|
||||
|
||||
test("parseMove returns None for input longer than 4 chars"):
|
||||
Parser.parseMove("e2e44") shouldBe None
|
||||
test("parseMove returns None for input longer than 5 chars"):
|
||||
Parser.parseMove("e2e4qq") shouldBe None
|
||||
|
||||
test("parseMove returns None when from-file is out of range"):
|
||||
Parser.parseMove("z2e4") shouldBe None
|
||||
@@ -41,3 +42,31 @@ class ParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseMove returns None when to-rank is out of range"):
|
||||
Parser.parseMove("e2e9") shouldBe None
|
||||
|
||||
test("parseMove parses queen promotion 'e7e8q'"):
|
||||
Parser.parseMove("e7e8q") shouldBe Some(
|
||||
(Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Queen)),
|
||||
)
|
||||
|
||||
test("parseMove parses rook promotion 'a7a8r'"):
|
||||
Parser.parseMove("a7a8r") shouldBe Some(
|
||||
(Square(File.A, Rank.R7), Square(File.A, Rank.R8), Some(PromotionPiece.Rook)),
|
||||
)
|
||||
|
||||
test("parseMove parses bishop promotion 'e7e8b'"):
|
||||
Parser.parseMove("e7e8b") shouldBe Some(
|
||||
(Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Bishop)),
|
||||
)
|
||||
|
||||
test("parseMove parses knight promotion 'e7e8n'"):
|
||||
Parser.parseMove("e7e8n") shouldBe Some(
|
||||
(Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Knight)),
|
||||
)
|
||||
|
||||
test("parseMove returns None for 5-char input with invalid promotion char"):
|
||||
Parser.parseMove("e7e8x") shouldBe None
|
||||
|
||||
test("parseMove parses black promotion 'e2e1q'"):
|
||||
Parser.parseMove("e2e1q") shouldBe Some(
|
||||
(Square(File.E, Rank.R2), Square(File.E, Rank.R1), Some(PromotionPiece.Queen)),
|
||||
)
|
||||
|
||||
@@ -24,10 +24,11 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
||||
observer.events.last shouldBe a[CheckmateEvent]
|
||||
|
||||
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get
|
||||
event.winner shouldBe Color.Black
|
||||
observer.events.last match
|
||||
case event: CheckmateEvent =>
|
||||
event.winner shouldBe Color.Black
|
||||
case other =>
|
||||
fail(s"Expected CheckmateEvent, but got $other")
|
||||
|
||||
test("GameEngine handles check detection"):
|
||||
val engine = new GameEngine()
|
||||
@@ -83,9 +84,8 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
observer.events.clear()
|
||||
engine.processUserInput(moves.last)
|
||||
|
||||
val drawEvents = observer.events.collect { case e: DrawEvent => e }
|
||||
drawEvents.size shouldBe 1
|
||||
drawEvents.head.reason shouldBe DrawReason.Stalemate
|
||||
val stalemateEvents = observer.events.collect { case DrawEvent(_, DrawReason.Stalemate) => true }
|
||||
stalemateEvents.size shouldBe 1
|
||||
|
||||
private class EndingMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
+11
-5
@@ -38,7 +38,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
events.count { case _: InvalidMoveEvent => true; case _ => false } should be >= 3
|
||||
events.count {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
} should be >= 3
|
||||
|
||||
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
|
||||
val engine = new GameEngine()
|
||||
@@ -69,7 +72,10 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.context shouldBe target
|
||||
engine.commandHistory shouldBe empty
|
||||
events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true
|
||||
events.lastOption.exists {
|
||||
case _: de.nowchess.chess.observer.BoardResetEvent => true
|
||||
case _ => false
|
||||
} shouldBe true
|
||||
|
||||
test("redo event includes captured piece description when replaying a capture"):
|
||||
val engine = new GameEngine()
|
||||
@@ -109,7 +115,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.loadGame(importer, "ignored") shouldBe Right(())
|
||||
engine.context.moves.lastOption shouldBe Some(promotionMove)
|
||||
|
||||
test("loadGame replay restores previous context when promotion cannot be completed"):
|
||||
test("loadGame replay restores previous context when move has no legal candidates"):
|
||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val noLegalMoves = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
@@ -134,7 +140,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val result = engine.loadGame(importer, "ignored")
|
||||
|
||||
result.isLeft shouldBe true
|
||||
result.left.toOption.get should include("Promotion required")
|
||||
result.left.toOption.get should include("Illegal move")
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||
@@ -150,7 +156,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Illegal move.")
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("normalMoveNotation handles missing source piece"):
|
||||
|
||||
@@ -2,7 +2,6 @@ package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
@@ -43,7 +42,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White castles queenside: e1c1
|
||||
engine.processUserInput("e1c1")
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -68,7 +70,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White pawn on e5 captures en passant to d6
|
||||
engine.processUserInput("e5d6")
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
|
||||
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||
@@ -84,8 +89,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
||||
|
||||
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
|
||||
// Extra white pawn on h2 ensures K+B+P vs K — sufficient material, so draw is not triggered
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get
|
||||
// White rook on h2 keeps material sufficient (K+R+B vs K) after bishop promotion
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6R/7K").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -94,8 +99,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
val engine = new GameEngine(ctx)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
engine.processUserInput("e7e8b")
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -106,8 +110,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
// ── King normal move notation (line 246) ───────────────────────────
|
||||
|
||||
test("undo after king move emits MoveUndoneEvent with K notation"):
|
||||
// White king on e1, white rook on h1 — K+R vs K ensures sufficient material after the king move
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get
|
||||
// Black pawn on h7 prevents K-vs-K insufficient-material draw; white king on e1, no castling rights
|
||||
val board = FenParser.parseBoard("k7/7p/8/8/8/8/8/4K3").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
@@ -118,7 +122,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// King moves e1 -> f1
|
||||
engine.processUserInput("e1f1")
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
@@ -251,10 +251,10 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("f3g1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("f6g8") // 3rd occurrence of initial position
|
||||
engine.processUserInput("f6g8") // 3rd occurrence of initial position
|
||||
|
||||
observer.hasEvent[ThreefoldRepetitionAvailableEvent] shouldBe true
|
||||
engine.context.result shouldBe None // claimable, not automatic
|
||||
engine.context.result shouldBe None // claimable, not automatic
|
||||
|
||||
test("draw claim via threefold repetition ends game with DrawEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
@@ -268,7 +268,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("g1f3")
|
||||
engine.processUserInput("g8f6")
|
||||
engine.processUserInput("f3g1")
|
||||
engine.processUserInput("f6g8") // threefold now available
|
||||
engine.processUserInput("f6g8") // threefold now available
|
||||
|
||||
observer.clear()
|
||||
engine.processUserInput("draw")
|
||||
|
||||
+77
-74
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
@@ -22,136 +22,143 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
private def engineWith(board: Board, turn: Color = Color.White): GameEngine =
|
||||
new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
||||
|
||||
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
||||
test("processUserInput without promotion suffix fires InvalidMoveEvent when pawn reaches back rank") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
events.exists { case _: PromotionRequiredEvent => true; case _ => false } should be(true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||
events.exists {
|
||||
case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required")
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("isPendingPromotion is true after PromotionRequired input") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
engine.isPendingPromotion should be(true)
|
||||
}
|
||||
|
||||
test("isPendingPromotion is false before any promotion input") {
|
||||
val engine = new GameEngine()
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||
test("processUserInput with queen promotion fires MoveExecutedEvent and places queen") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with rook underpromotion") {
|
||||
test("processUserInput with rook underpromotion places rook") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
engine.processUserInput("e7e8r")
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||
}
|
||||
|
||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
test("processUserInput with bishop underpromotion places bishop") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8b")
|
||||
|
||||
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Bishop)))
|
||||
}
|
||||
|
||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||
test("processUserInput with knight underpromotion places knight") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8n")
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Knight)))
|
||||
}
|
||||
|
||||
test("processUserInput e7e8q fires CheckDetectedEvent when promotion gives check") {
|
||||
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||
test("processUserInput e7e8q does not fire CheckDetectedEvent when promotion doesn't give check") {
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.collect { case e: MoveExecutedEvent => e } should not be empty
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
|
||||
events.filter {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should not be empty
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
case _ => false
|
||||
} should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||
test("processUserInput h7h8q fires CheckmateEvent when promotion delivers checkmate") {
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("h7h8q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: CheckmateEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: CheckmateEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||
test("processUserInput b7b8n fires DrawEvent with Stalemate when promotion creates stalemate") {
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("b7b8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
engine.processUserInput("b7b8n")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: DrawEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case DrawEvent(_, DrawReason.Stalemate) => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
}
|
||||
|
||||
test("completePromotion with black pawn promotion results in Moved") {
|
||||
test("processUserInput e2e1q with black pawn promotes to queen") {
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||
val engine = engineWith(board, Color.Black)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e2e1q")
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.collect { case e: MoveExecutedEvent => e } should not be empty
|
||||
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
|
||||
events.filter {
|
||||
case _: MoveExecutedEvent => true
|
||||
case _ => false
|
||||
} should not be empty
|
||||
events.exists {
|
||||
case _: CheckDetectedEvent => true
|
||||
case _ => false
|
||||
} should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||
// Custom RuleSet: delegates all methods to StandardRules except legalMoves,
|
||||
// which strips Promotion move types and returns Normal moves instead.
|
||||
// This makes completePromotion unable to find Move(from, to, Promotion(Queen)),
|
||||
// triggering the "Error completing promotion." branch.
|
||||
test("processUserInput fires InvalidMoveEvent when promotion piece has no matching legal move") {
|
||||
// Custom RuleSet: strips Promotion move types and returns Normal moves instead,
|
||||
// so Move(e7, e8, Promotion(Queen)) is not in legal moves — triggers error branch.
|
||||
val delegatingRuleSet: RuleSet = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||
DefaultRules.candidateMoves(context)(square)
|
||||
@@ -183,17 +190,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||
engine.processUserInput("e7e8")
|
||||
engine.isPendingPromotion should be(true)
|
||||
// legalMoves returns Normal candidates (non-empty) but no Promotion(Queen) move
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||
// but only Normal moves exist → fires InvalidMoveEvent
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
|
||||
events.exists {
|
||||
case _: InvalidMoveEvent => true
|
||||
case _ => false
|
||||
} should be(true)
|
||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||
invalidEvt.reason should include("Error completing promotion")
|
||||
}
|
||||
|
||||
+19
-38
@@ -1,7 +1,6 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -89,7 +88,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// ── Pawn promotion ─────────────────────────────────────────────
|
||||
|
||||
test("pawn reaching back rank requires promotion"):
|
||||
test("pawn reaching back rank without promotion suffix fires InvalidMoveEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
@@ -99,74 +98,61 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
observer.hasEvent[PromotionRequiredEvent] shouldBe true
|
||||
engine.isPendingPromotion shouldBe true
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
test("completePromotion to Queen executes move"):
|
||||
test("e7e8q promotes to Queen"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Rook executes move"):
|
||||
test("e7e8r promotes to Rook"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
engine.processUserInput("e7e8r")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Bishop executes move"):
|
||||
test("e7e8b promotes to Bishop"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
engine.processUserInput("e7e8b")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Knight executes move"):
|
||||
test("e7e8n promotes to Knight"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
engine.processUserInput("e7e8n")
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
||||
test("promotion with discovered check emits CheckDetectedEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: white pawn e7, black king e6, white king e1
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("e7e8q")
|
||||
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
||||
test("promotion with checkmate emits CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: known promotion-mate pattern
|
||||
EngineTestHelpers.loadFen(engine, "k7/7P/1K6/8/8/8/8/8 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
engine.processUserInput("h7h8q")
|
||||
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
@@ -177,8 +163,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White rook on h2 keeps material sufficient (K+B+R vs K) after bishop promotion
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
engine.processUserInput("e7e8b")
|
||||
observer.clear()
|
||||
|
||||
engine.undo()
|
||||
@@ -187,16 +172,12 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.pgnNotation shouldBe "e8=B"
|
||||
|
||||
test("black pawn promotion executes"):
|
||||
test("black pawn e2e1q promotes to queen"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/8/8/8/8/4k3/4p3/8 b - - 0 1")
|
||||
engine.processUserInput("e2e1")
|
||||
engine.processUserInput("e2e1q")
|
||||
|
||||
engine.isPendingPromotion shouldBe true
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Promotion capturing ────────────────────────────────────────
|
||||
@@ -205,6 +186,6 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "3n4/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||
engine.processUserInput("e7d8")
|
||||
engine.processUserInput("e7d8q")
|
||||
|
||||
engine.isPendingPromotion shouldBe true
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.bot.Bot
|
||||
import de.nowchess.api.game.{BotParticipant, GameContext, Human}
|
||||
import de.nowchess.api.move.{Move, MoveType}
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import de.nowchess.bot.bots.ClassicalBot
|
||||
import de.nowchess.bot.{BotController, BotDifficulty}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
|
||||
|
||||
private class NoMoveBot extends Bot:
|
||||
def name: String = "nomove"
|
||||
def nextMove(context: GameContext): Option[Move] = None
|
||||
|
||||
private class FixedMoveBot(move: Move) extends Bot:
|
||||
def name: String = "fixed"
|
||||
def nextMove(context: GameContext): Option[Move] = Some(move)
|
||||
|
||||
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine can play against a ClassicalBot"):
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
|
||||
)
|
||||
|
||||
// Collect events
|
||||
val moveCount = new AtomicInteger(0)
|
||||
val checkmateDetected = new AtomicBoolean(false)
|
||||
val gameEnded = new AtomicBoolean(false)
|
||||
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent =>
|
||||
moveCount.incrementAndGet()
|
||||
case _: CheckmateEvent =>
|
||||
checkmateDetected.set(true)
|
||||
gameEnded.set(true)
|
||||
case _: DrawEvent =>
|
||||
gameEnded.set(true)
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Play a few moves: e2e4, then let the bot respond
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
// Wait a bit for the bot to respond asynchronously
|
||||
Thread.sleep(5000)
|
||||
|
||||
// White should have moved, then Black (bot) should have responded
|
||||
moveCount.get() should be >= 2
|
||||
|
||||
test("BotController can list and retrieve bots"):
|
||||
val bots = BotController.listBots
|
||||
bots should contain("easy")
|
||||
bots should contain("medium")
|
||||
bots should contain("hard")
|
||||
bots should contain("expert")
|
||||
|
||||
BotController.getBot("easy") should not be None
|
||||
BotController.getBot("medium") should not be None
|
||||
BotController.getBot("hard") should not be None
|
||||
BotController.getBot("expert") should not be None
|
||||
BotController.getBot("unknown") should be(None)
|
||||
|
||||
test("GameEngine handles bot with different difficulty"):
|
||||
val hardBot = BotController.getBot("hard").get
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
|
||||
)
|
||||
engine.turn should equal(Color.White)
|
||||
|
||||
val movesMade = new AtomicInteger(0)
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent => movesMade.incrementAndGet()
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// White moves
|
||||
engine.processUserInput("d2d4")
|
||||
Thread.sleep(500) // Wait for bot response
|
||||
|
||||
// At least white moved, possibly black also responded
|
||||
movesMade.get() should be >= 1
|
||||
|
||||
test("GameEngine plays valid bot moves"):
|
||||
val bot = ClassicalBot(BotDifficulty.Easy)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
|
||||
)
|
||||
|
||||
val moveCount = new AtomicInteger(0)
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case _: MoveExecutedEvent => moveCount.incrementAndGet()
|
||||
case _ => ()
|
||||
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Play a normal move
|
||||
engine.processUserInput("e2e4")
|
||||
Thread.sleep(1000)
|
||||
|
||||
// The game should have progressed with at least one move
|
||||
moveCount.get() should be >= 1
|
||||
// Game should not be ended (checkmate/stalemate)
|
||||
engine.context.moves.nonEmpty should be(true)
|
||||
|
||||
test("startGame triggers bot when the starting player is a bot"):
|
||||
val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
|
||||
)
|
||||
val movesMade = new AtomicInteger(0)
|
||||
engine.subscribe(
|
||||
new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = event match
|
||||
case _: MoveExecutedEvent => movesMade.incrementAndGet()
|
||||
case _ => (),
|
||||
)
|
||||
engine.startGame()
|
||||
Thread.sleep(500)
|
||||
movesMade.get() should be >= 1
|
||||
|
||||
test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
|
||||
val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
|
||||
val bot = new FixedMoveBot(illegalMove)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
|
||||
)
|
||||
val invalidCount = new AtomicInteger(0)
|
||||
engine.subscribe(
|
||||
new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = event match
|
||||
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
|
||||
case _ => (),
|
||||
)
|
||||
engine.processUserInput("e2e4")
|
||||
Thread.sleep(1000)
|
||||
invalidCount.get() should be >= 1
|
||||
|
||||
test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
|
||||
val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
|
||||
val bot = new FixedMoveBot(invalidMove)
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
|
||||
)
|
||||
val invalidCount = new AtomicInteger(0)
|
||||
engine.subscribe(
|
||||
new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = event match
|
||||
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
|
||||
case _ => (),
|
||||
)
|
||||
engine.processUserInput("e2e4")
|
||||
Thread.sleep(1000)
|
||||
invalidCount.get() should be >= 1
|
||||
|
||||
test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
|
||||
// White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.A, Rank.R1) -> Piece.WhiteKing,
|
||||
Square(File.B, Rank.R2) -> Piece.BlackQueen,
|
||||
Square(File.B, Rank.R8) -> Piece.BlackRook,
|
||||
Square(File.H, Rank.R8) -> Piece.BlackKing,
|
||||
),
|
||||
)
|
||||
val ctx = GameContext.initial.copy(
|
||||
board = board,
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights(false, false, false, false),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty,
|
||||
)
|
||||
val engine = GameEngine(
|
||||
ctx,
|
||||
DefaultRules,
|
||||
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
|
||||
)
|
||||
val checkmateCount = new AtomicInteger(0)
|
||||
engine.subscribe(
|
||||
new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = event match
|
||||
case _: CheckmateEvent => checkmateCount.incrementAndGet()
|
||||
case _ => (),
|
||||
)
|
||||
engine.startGame()
|
||||
Thread.sleep(1000)
|
||||
checkmateCount.get() should be >= 1
|
||||
|
||||
test("handleBotNoMove fires DrawEvent when position is stalemate"):
|
||||
// White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.A, Rank.R1) -> Piece.WhiteKing,
|
||||
Square(File.B, Rank.R3) -> Piece.BlackQueen,
|
||||
Square(File.H, Rank.R8) -> Piece.BlackKing,
|
||||
),
|
||||
)
|
||||
val ctx = GameContext.initial.copy(
|
||||
board = board,
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights(false, false, false, false),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty,
|
||||
)
|
||||
val engine = GameEngine(
|
||||
ctx,
|
||||
DefaultRules,
|
||||
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
|
||||
)
|
||||
val drawCount = new AtomicInteger(0)
|
||||
engine.subscribe(
|
||||
new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = event match
|
||||
case _: DrawEvent => drawCount.incrementAndGet()
|
||||
case _ => (),
|
||||
)
|
||||
engine.startGame()
|
||||
Thread.sleep(1000)
|
||||
drawCount.get() should be >= 1
|
||||
|
||||
test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
|
||||
val engine = GameEngine(
|
||||
GameContext.initial,
|
||||
DefaultRules,
|
||||
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
|
||||
)
|
||||
val unexpectedEvents = new AtomicInteger(0)
|
||||
engine.subscribe(
|
||||
new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = event match
|
||||
case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
|
||||
case _: DrawEvent => unexpectedEvents.incrementAndGet()
|
||||
case _ => (),
|
||||
)
|
||||
engine.startGame()
|
||||
Thread.sleep(500)
|
||||
unexpectedEvents.get() shouldBe 0
|
||||
Reference in New Issue
Block a user