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
@@ -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,
@@ -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]()
@@ -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")
@@ -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")
}
@@ -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