feat: Refactor promotion handling to use UCI notation and improve move validation
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-16 18:48:46 +02:00
parent 95537bc709
commit 96b8249e7e
17 changed files with 214 additions and 236 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
@@ -19,13 +19,8 @@ class GameEngine(
val ruleSet: RuleSet = DefaultRules,
val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human),
) extends Observable:
// Ensure that initialBoard is set correctly for threefold repetition detection
private val contextWithInitialBoard = if initialContext.moves.isEmpty && initialContext.board != initialContext.initialBoard then
initialContext.copy(initialBoard = initialContext.board)
else
initialContext
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
@@ -72,15 +67,11 @@ class GameEngine(
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else
notifyObservers(
InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.",
"Draw cannot be claimed: the 50-move rule has not been triggered.",
),
)
@@ -167,7 +158,7 @@ class GameEngine(
invoker.clear()
if ctx.moves.isEmpty then
currentContext = ctx.copy(initialBoard = ctx.board)
currentContext = ctx
Right(())
else replayMoves(ctx.moves, savedContext)
@@ -195,12 +186,7 @@ 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
currentContext = contextWithInitialBoard
pendingPromotion = None
currentContext = newContext
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
@@ -257,7 +243,6 @@ 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))
// Request bot move if it's the opponent bot's turn
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
@@ -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,
@@ -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)),
)
@@ -1,8 +1,9 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
import de.nowchess.api.board.Color
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -29,10 +30,6 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
case other =>
fail(s"Expected CheckmateEvent, but got $other")
// Board should be reset after checkmate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine handles check detection"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
@@ -87,13 +84,9 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
observer.events.clear()
engine.processUserInput(moves.last)
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
val stalemateEvents = observer.events.collect { case DrawEvent(_, DrawReason.Stalemate) => true }
stalemateEvents.size shouldBe 1
// Board should be reset after stalemate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
@@ -115,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
@@ -140,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"):
@@ -156,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
@@ -90,7 +89,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── Bishop underpromotion notation (line 230) ──────────────────────
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/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)
@@ -99,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()
@@ -111,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, no castling rights, black king far away
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").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)
@@ -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,7 +22,7 @@ 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)
@@ -30,36 +30,18 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8")
events.exists {
case _: PromotionRequiredEvent => true
case _ => false
case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required")
case _ => false
} should be(true)
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
}
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)
@@ -69,37 +51,42 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
} 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
@@ -107,15 +94,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
} 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.filter {
case _: MoveExecutedEvent => true
@@ -127,45 +112,39 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
} 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)
}
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 _: StalemateEvent => true
case _ => false
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.filter {
case _: MoveExecutedEvent => true
@@ -177,11 +156,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
} 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)
@@ -213,16 +190,9 @@ 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
@@ -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
@@ -33,7 +33,7 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers:
case _: CheckmateEvent =>
checkmateDetected.set(true)
gameEnded.set(true)
case _: StalemateEvent =>
case _: DrawEvent =>
gameEnded.set(true)
case _ => ()