feat: Refactor promotion handling to use UCI notation and improve move validation
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"created": "2026-04-13T19:58:38.629943",
|
"created": "2026-04-13T19:58:38.629943",
|
||||||
"total_positions": 2522562,
|
"total_positions": 3022562,
|
||||||
"stockfish_depth": 12,
|
"stockfish_depth": 12,
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
@@ -46,6 +46,21 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actual_count": 599993
|
"actual_count": 599993
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "merged_sources",
|
||||||
|
"count": 500000,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "lichess",
|
||||||
|
"count": 500000,
|
||||||
|
"params": {
|
||||||
|
"min_depth": 20,
|
||||||
|
"max_positions": 500000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actual_count": 500000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -1,21 +1,28 @@
|
|||||||
package de.nowchess.chess.controller
|
package de.nowchess.chess.controller
|
||||||
|
|
||||||
import de.nowchess.api.board.{File, Rank, Square}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
object Parser:
|
object Parser:
|
||||||
|
|
||||||
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
/** Parses UCI move notation: "e2e4" (4 chars) or "e7e8q" (5 chars with promotion piece suffix).
|
||||||
* format.
|
* 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
|
val trimmed = input.trim.toLowerCase
|
||||||
Option
|
trimmed.length match
|
||||||
.when(trimmed.length == 4)(trimmed)
|
case 4 =>
|
||||||
.flatMap: s =>
|
|
||||||
for
|
for
|
||||||
from <- parseSquare(s.substring(0, 2))
|
from <- parseSquare(trimmed.substring(0, 2))
|
||||||
to <- parseSquare(s.substring(2, 4))
|
to <- parseSquare(trimmed.substring(2, 4))
|
||||||
yield (from, to)
|
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] =
|
private def parseSquare(s: String): Option[Square] =
|
||||||
Option
|
Option
|
||||||
@@ -26,3 +33,10 @@ object Parser:
|
|||||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||||
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
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 ruleSet: RuleSet = DefaultRules,
|
||||||
val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human),
|
val participants: Map[Color, Participant] = Map(Color.White -> Human, Color.Black -> Human),
|
||||||
) extends Observable:
|
) 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"))
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentContext: GameContext = contextWithInitialBoard
|
private var currentContext: GameContext = initialContext
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
/** 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)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
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
|
else
|
||||||
notifyObservers(
|
notifyObservers(
|
||||||
InvalidMoveEvent(
|
InvalidMoveEvent(
|
||||||
currentContext,
|
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()
|
invoker.clear()
|
||||||
|
|
||||||
if ctx.moves.isEmpty then
|
if ctx.moves.isEmpty then
|
||||||
currentContext = ctx.copy(initialBoard = ctx.board)
|
currentContext = ctx
|
||||||
Right(())
|
Right(())
|
||||||
else replayMoves(ctx.moves, savedContext)
|
else replayMoves(ctx.moves, savedContext)
|
||||||
|
|
||||||
@@ -195,12 +186,7 @@ class GameEngine(
|
|||||||
|
|
||||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||||
def loadPosition(newContext: GameContext): Unit = synchronized {
|
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||||
val contextWithInitialBoard = if newContext.moves.isEmpty then
|
currentContext = newContext
|
||||||
newContext.copy(initialBoard = newContext.board)
|
|
||||||
else
|
|
||||||
newContext
|
|
||||||
currentContext = contextWithInitialBoard
|
|
||||||
pendingPromotion = None
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(currentContext))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
@@ -257,7 +243,6 @@ class GameEngine(
|
|||||||
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
|
|
||||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(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
|
// Request bot move if it's the opponent bot's turn
|
||||||
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
|
if ruleSet.isCheckmate(currentContext) || ruleSet.isStalemate(currentContext) then
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.observer
|
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}
|
import de.nowchess.api.game.{DrawReason, GameContext}
|
||||||
|
|
||||||
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||||
@@ -39,13 +39,6 @@ case class InvalidMoveEvent(
|
|||||||
reason: String,
|
reason: String,
|
||||||
) extends GameEvent
|
) 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. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
package de.nowchess.chess.controller
|
package de.nowchess.chess.controller
|
||||||
|
|
||||||
import de.nowchess.api.board.{File, Rank, Square}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class ParserTest extends AnyFunSuite with Matchers:
|
class ParserTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("parseMove parses valid 'e2e4'"):
|
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"):
|
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"):
|
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"):
|
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"):
|
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"):
|
test("parseMove returns None for empty string"):
|
||||||
Parser.parseMove("") shouldBe None
|
Parser.parseMove("") shouldBe None
|
||||||
@@ -27,8 +28,8 @@ class ParserTest extends AnyFunSuite with Matchers:
|
|||||||
test("parseMove returns None for input shorter than 4 chars"):
|
test("parseMove returns None for input shorter than 4 chars"):
|
||||||
Parser.parseMove("e2e") shouldBe None
|
Parser.parseMove("e2e") shouldBe None
|
||||||
|
|
||||||
test("parseMove returns None for input longer than 4 chars"):
|
test("parseMove returns None for input longer than 5 chars"):
|
||||||
Parser.parseMove("e2e44") shouldBe None
|
Parser.parseMove("e2e4qq") shouldBe None
|
||||||
|
|
||||||
test("parseMove returns None when from-file is out of range"):
|
test("parseMove returns None when from-file is out of range"):
|
||||||
Parser.parseMove("z2e4") shouldBe None
|
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"):
|
test("parseMove returns None when to-rank is out of range"):
|
||||||
Parser.parseMove("e2e9") shouldBe None
|
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)),
|
||||||
|
)
|
||||||
|
|||||||
+4
-11
@@ -1,8 +1,9 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.Color
|
||||||
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
|
import de.nowchess.api.game.DrawReason
|
||||||
|
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -29,10 +30,6 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
|||||||
case other =>
|
case other =>
|
||||||
fail(s"Expected CheckmateEvent, but got $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"):
|
test("GameEngine handles check detection"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val observer = new EndingMockObserver()
|
val observer = new EndingMockObserver()
|
||||||
@@ -87,13 +84,9 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
|||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
engine.processUserInput(moves.last)
|
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
|
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:
|
private class EndingMockObserver extends Observer:
|
||||||
val events = mutable.ListBuffer[GameEvent]()
|
val events = mutable.ListBuffer[GameEvent]()
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -115,7 +115,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
engine.loadGame(importer, "ignored") shouldBe Right(())
|
engine.loadGame(importer, "ignored") shouldBe Right(())
|
||||||
engine.context.moves.lastOption shouldBe Some(promotionMove)
|
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 promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
val noLegalMoves = new RuleSet:
|
val noLegalMoves = new RuleSet:
|
||||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
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")
|
val result = engine.loadGame(importer, "ignored")
|
||||||
|
|
||||||
result.isLeft shouldBe true
|
result.isLeft shouldBe true
|
||||||
result.left.toOption.get should include("Promotion required")
|
result.left.toOption.get should include("Illegal move")
|
||||||
engine.context shouldBe saved
|
engine.context shouldBe saved
|
||||||
|
|
||||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
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 illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
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
|
engine.context shouldBe saved
|
||||||
|
|
||||||
test("normalMoveNotation handles missing source piece"):
|
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.board.{Board, Color, File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
@@ -90,7 +89,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
||||||
|
|
||||||
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
|
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
|
val ctx = GameContext.initial
|
||||||
.withBoard(board)
|
.withBoard(board)
|
||||||
.withTurn(Color.White)
|
.withTurn(Color.White)
|
||||||
@@ -99,8 +99,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
val engine = new GameEngine(ctx)
|
val engine = new GameEngine(ctx)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8b")
|
||||||
engine.completePromotion(PromotionPiece.Bishop)
|
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
@@ -111,8 +110,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
// ── King normal move notation (line 246) ───────────────────────────
|
// ── King normal move notation (line 246) ───────────────────────────
|
||||||
|
|
||||||
test("undo after king move emits MoveUndoneEvent with K notation"):
|
test("undo after king move emits MoveUndoneEvent with K notation"):
|
||||||
// White king on e1, no castling rights, black king far away
|
// Black pawn on h7 prevents K-vs-K insufficient-material draw; white king on e1, no castling rights
|
||||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
|
val board = FenParser.parseBoard("k7/7p/8/8/8/8/8/4K3").get
|
||||||
val ctx = GameContext.initial
|
val ctx = GameContext.initial
|
||||||
.withBoard(board)
|
.withBoard(board)
|
||||||
.withTurn(Color.White)
|
.withTurn(Color.White)
|
||||||
|
|||||||
+41
-71
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
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.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
import de.nowchess.chess.observer.*
|
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 =
|
private def engineWith(board: Board, turn: Color = Color.White): GameEngine =
|
||||||
new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
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 promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
@@ -30,36 +30,18 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
|
|
||||||
events.exists {
|
events.exists {
|
||||||
case _: PromotionRequiredEvent => true
|
case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required")
|
||||||
case _ => false
|
case _ => false
|
||||||
} should be(true)
|
} 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") {
|
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)
|
|
||||||
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") {
|
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
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.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
@@ -69,37 +51,42 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
} should be(true)
|
} 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 promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
captureEvents(engine)
|
captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8r")
|
||||||
engine.completePromotion(PromotionPiece.Rook)
|
|
||||||
|
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
test("processUserInput with bishop underpromotion places bishop") {
|
||||||
val engine = new GameEngine()
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val events = captureEvents(engine)
|
val engine = engineWith(promotionBoard)
|
||||||
|
captureEvents(engine)
|
||||||
|
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.processUserInput("e7e8b")
|
||||||
|
|
||||||
events.exists {
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Bishop)))
|
||||||
case _: InvalidMoveEvent => true
|
|
||||||
case _ => false
|
|
||||||
} should be(true)
|
|
||||||
engine.isPendingPromotion should be(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
events.exists {
|
events.exists {
|
||||||
case _: CheckDetectedEvent => true
|
case _: CheckDetectedEvent => true
|
||||||
@@ -107,15 +94,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
} should be(true)
|
} 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 board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||||
val engine = engineWith(board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
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.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||||
events.filter {
|
events.filter {
|
||||||
case _: MoveExecutedEvent => true
|
case _: MoveExecutedEvent => true
|
||||||
@@ -127,45 +112,39 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
} should be(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 board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||||
val engine = engineWith(board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("h7h8")
|
engine.processUserInput("h7h8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
engine.isPendingPromotion should be(false)
|
|
||||||
events.exists {
|
events.exists {
|
||||||
case _: CheckmateEvent => true
|
case _: CheckmateEvent => true
|
||||||
case _ => false
|
case _ => false
|
||||||
} should be(true)
|
} 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 board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||||
val engine = engineWith(board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("b7b8")
|
engine.processUserInput("b7b8n")
|
||||||
engine.completePromotion(PromotionPiece.Knight)
|
|
||||||
|
|
||||||
engine.isPendingPromotion should be(false)
|
|
||||||
events.exists {
|
events.exists {
|
||||||
case _: StalemateEvent => true
|
case DrawEvent(_, DrawReason.Stalemate) => true
|
||||||
case _ => false
|
case _ => false
|
||||||
} should be(true)
|
} 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 board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||||
val engine = engineWith(board, Color.Black)
|
val engine = engineWith(board, Color.Black)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e2e1")
|
engine.processUserInput("e2e1q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
engine.isPendingPromotion should be(false)
|
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||||
events.filter {
|
events.filter {
|
||||||
case _: MoveExecutedEvent => true
|
case _: MoveExecutedEvent => true
|
||||||
@@ -177,11 +156,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
} should be(false)
|
} should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
test("processUserInput fires InvalidMoveEvent when promotion piece has no matching legal move") {
|
||||||
// Custom RuleSet: delegates all methods to StandardRules except legalMoves,
|
// Custom RuleSet: strips Promotion move types and returns Normal moves instead,
|
||||||
// which strips Promotion move types and returns Normal moves instead.
|
// so Move(e7, e8, Promotion(Queen)) is not in legal moves — triggers error branch.
|
||||||
// This makes completePromotion unable to find Move(from, to, Promotion(Queen)),
|
|
||||||
// triggering the "Error completing promotion." branch.
|
|
||||||
val delegatingRuleSet: RuleSet = new RuleSet:
|
val delegatingRuleSet: RuleSet = new RuleSet:
|
||||||
def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||||
DefaultRules.candidateMoves(context)(square)
|
DefaultRules.candidateMoves(context)(square)
|
||||||
@@ -213,16 +190,9 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
// legalMoves returns Normal candidates (non-empty) but no Promotion(Queen) move
|
||||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
engine.processUserInput("e7e8q")
|
||||||
engine.processUserInput("e7e8")
|
|
||||||
engine.isPendingPromotion should be(true)
|
|
||||||
|
|
||||||
// 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 {
|
events.exists {
|
||||||
case _: InvalidMoveEvent => true
|
case _: InvalidMoveEvent => true
|
||||||
case _ => false
|
case _ => false
|
||||||
|
|||||||
+19
-38
@@ -1,7 +1,6 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.Color
|
import de.nowchess.api.board.Color
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -89,7 +88,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// ── Pawn promotion ─────────────────────────────────────────────
|
// ── Pawn promotion ─────────────────────────────────────────────
|
||||||
|
|
||||||
test("pawn reaching back rank requires promotion"):
|
test("pawn reaching back rank without promotion suffix fires InvalidMoveEvent"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
@@ -99,74 +98,61 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
|
|
||||||
observer.hasEvent[PromotionRequiredEvent] shouldBe true
|
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||||
engine.isPendingPromotion shouldBe true
|
|
||||||
|
|
||||||
test("completePromotion to Queen executes move"):
|
test("e7e8q promotes to Queen"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
engine.isPendingPromotion shouldBe false
|
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
test("completePromotion to Rook executes move"):
|
test("e7e8r promotes to Rook"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8r")
|
||||||
engine.completePromotion(PromotionPiece.Rook)
|
|
||||||
|
|
||||||
engine.isPendingPromotion shouldBe false
|
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
test("completePromotion to Bishop executes move"):
|
test("e7e8b promotes to Bishop"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8b")
|
||||||
engine.completePromotion(PromotionPiece.Bishop)
|
|
||||||
|
|
||||||
engine.isPendingPromotion shouldBe false
|
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
test("completePromotion to Knight executes move"):
|
test("e7e8n promotes to Knight"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8n")
|
||||||
engine.completePromotion(PromotionPiece.Knight)
|
|
||||||
|
|
||||||
engine.isPendingPromotion shouldBe false
|
|
||||||
engine.turn shouldBe Color.Black
|
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 engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
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")
|
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||||
observer.clear()
|
observer.clear()
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||||
|
|
||||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
test("promotion with checkmate emits CheckmateEvent"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
// FEN: known promotion-mate pattern
|
|
||||||
EngineTestHelpers.loadFen(engine, "k7/7P/1K6/8/8/8/8/8 w - - 0 1")
|
EngineTestHelpers.loadFen(engine, "k7/7P/1K6/8/8/8/8/8 w - - 0 1")
|
||||||
observer.clear()
|
observer.clear()
|
||||||
|
|
||||||
engine.processUserInput("h7h8")
|
engine.processUserInput("h7h8q")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
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
|
// 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")
|
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1")
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8b")
|
||||||
engine.completePromotion(PromotionPiece.Bishop)
|
|
||||||
observer.clear()
|
observer.clear()
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
@@ -187,16 +172,12 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
evt.isDefined shouldBe true
|
evt.isDefined shouldBe true
|
||||||
evt.get.pgnNotation shouldBe "e8=B"
|
evt.get.pgnNotation shouldBe "e8=B"
|
||||||
|
|
||||||
test("black pawn promotion executes"):
|
test("black pawn e2e1q promotes to queen"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "8/8/8/8/8/4k3/4p3/8 b - - 0 1")
|
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
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
// ── Promotion capturing ────────────────────────────────────────
|
// ── Promotion capturing ────────────────────────────────────────
|
||||||
@@ -205,6 +186,6 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "3n4/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
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 =>
|
case _: CheckmateEvent =>
|
||||||
checkmateDetected.set(true)
|
checkmateDetected.set(true)
|
||||||
gameEnded.set(true)
|
gameEnded.set(true)
|
||||||
case _: StalemateEvent =>
|
case _: DrawEvent =>
|
||||||
gameEnded.set(true)
|
gameEnded.set(true)
|
||||||
case _ => ()
|
case _ => ()
|
||||||
|
|
||||||
|
|||||||
@@ -455,13 +455,21 @@ object DefaultRules extends RuleSet:
|
|||||||
|
|
||||||
// ── Insufficient material ──────────────────────────────────────────
|
// ── Insufficient material ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
|
||||||
|
|
||||||
private def insufficientMaterial(board: Board): Boolean =
|
private def insufficientMaterial(board: Board): Boolean =
|
||||||
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
val nonKings = board.pieces.toList.filter(_._2.pieceType != PieceType.King)
|
||||||
pieces match
|
nonKings match
|
||||||
case Nil => true
|
case Nil => true
|
||||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||||
case List(p1, p2)
|
case List((sq1, p1), (sq2, p2))
|
||||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||||
&& p1.color != p2.color =>
|
&& p1.color != p2.color
|
||||||
|
&& squareColor(sq1) == squareColor(sq2) =>
|
||||||
|
true
|
||||||
|
case bishops
|
||||||
|
if bishops.forall(_._2.pieceType == PieceType.Bishop)
|
||||||
|
&& bishops.map(_._2.color).distinct.size == 1
|
||||||
|
&& bishops.map(e => squareColor(e._1)).distinct.size == 1 =>
|
||||||
true
|
true
|
||||||
case _ => false
|
case _ => false
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import scalafx.scene.shape.Rectangle
|
|||||||
import scalafx.scene.text.{Font, Text}
|
import scalafx.scene.text.{Font, Text}
|
||||||
import scalafx.stage.Stage
|
import scalafx.stage.Stage
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.MoveType
|
||||||
import de.nowchess.chess.command.{MoveCommand, MoveResult}
|
import de.nowchess.chess.command.{MoveCommand, MoveResult}
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||||
@@ -178,36 +178,37 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
square
|
square
|
||||||
|
|
||||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||||
if !engine.isPendingPromotion then
|
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
|
||||||
|
|
||||||
selectedSquare.get() match
|
selectedSquare.get() match
|
||||||
case None =>
|
case None =>
|
||||||
// First click - select piece if it belongs to current player
|
// First click - select piece if it belongs to current player
|
||||||
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
|
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
|
||||||
if piece.color == currentTurn.get() then
|
if piece.color == currentTurn.get() then
|
||||||
selectedSquare.set(Some(clickedSquare))
|
selectedSquare.set(Some(clickedSquare))
|
||||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||||
|
|
||||||
val legalDests = engine.ruleSet
|
val legalDests = engine.ruleSet
|
||||||
.legalMoves(engine.context)(clickedSquare)
|
.legalMoves(engine.context)(clickedSquare)
|
||||||
.collect { case move if move.from == clickedSquare => move.to }
|
.collect { case move if move.from == clickedSquare => move.to }
|
||||||
legalDests.foreach { sq =>
|
legalDests.foreach { sq =>
|
||||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case Some(fromSquare) =>
|
case Some(fromSquare) =>
|
||||||
// Second click - attempt move
|
// Second click - attempt move
|
||||||
if clickedSquare == fromSquare then
|
if clickedSquare == fromSquare then
|
||||||
// Deselect
|
// Deselect
|
||||||
selectedSquare.set(None)
|
selectedSquare.set(None)
|
||||||
updateBoard(currentBoard.get(), currentTurn.get())
|
updateBoard(currentBoard.get(), currentTurn.get())
|
||||||
else
|
else
|
||||||
// Try to move
|
val isPromo = engine.ruleSet
|
||||||
val moveStr = s"${fromSquare}$clickedSquare"
|
.legalMoves(engine.context)(fromSquare)
|
||||||
engine.processUserInput(moveStr)
|
.exists(m => m.to == clickedSquare && m.moveType.isInstanceOf[MoveType.Promotion])
|
||||||
selectedSquare.set(None)
|
if isPromo then showPromotionDialog(fromSquare, clickedSquare)
|
||||||
|
else engine.processUserInput(s"${fromSquare}$clickedSquare")
|
||||||
|
selectedSquare.set(None)
|
||||||
|
|
||||||
def updateBoard(board: Board, turn: Color): Unit =
|
def updateBoard(board: Board, turn: Color): Unit =
|
||||||
currentBoard.set(board)
|
currentBoard.set(board)
|
||||||
@@ -280,14 +281,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
headerText = "Choose promotion piece"
|
headerText = "Choose promotion piece"
|
||||||
contentText = "Promote to:"
|
contentText = "Promote to:"
|
||||||
}
|
}
|
||||||
|
val uciSuffix = dialog.showAndWait() match
|
||||||
val result = dialog.showAndWait()
|
case Some("Rook") => "r"
|
||||||
result match
|
case Some("Bishop") => "b"
|
||||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
case Some("Knight") => "n"
|
||||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
case _ => "q"
|
||||||
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
engine.processUserInput(s"${from}${to}$uciSuffix")
|
||||||
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
|
||||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
|
||||||
|
|
||||||
private def doFenExport(): Unit =
|
private def doFenExport(): Unit =
|
||||||
doExport(FenExporter, "FEN")
|
doExport(FenExporter, "FEN")
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
|||||||
boardView.updateBoard(e.context.board, e.context.turn)
|
boardView.updateBoard(e.context.board, e.context.turn)
|
||||||
boardView.showMessage("Board has been reset to initial position.")
|
boardView.showMessage("Board has been reset to initial position.")
|
||||||
|
|
||||||
case e: PromotionRequiredEvent =>
|
|
||||||
boardView.showPromotionDialog(e.from, e.to)
|
|
||||||
|
|
||||||
case e: FiftyMoveRuleAvailableEvent =>
|
case e: FiftyMoveRuleAvailableEvent =>
|
||||||
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
|
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
|
||||||
|
|
||||||
|
|||||||
@@ -6,26 +6,24 @@ import de.nowchess.api.board.{Color, Piece, PieceType}
|
|||||||
/** Utility object for loading chess piece sprites. */
|
/** Utility object for loading chess piece sprites. */
|
||||||
object PieceSprites:
|
object PieceSprites:
|
||||||
|
|
||||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
|
||||||
|
|
||||||
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||||
*/
|
*/
|
||||||
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
|
||||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||||
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
|
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
|
||||||
|
new ImageView(image) {
|
||||||
new ImageView(image) {
|
fitWidth = size
|
||||||
fitWidth = size
|
fitHeight = size
|
||||||
fitHeight = size
|
preserveRatio = true
|
||||||
preserveRatio = true
|
smooth = true
|
||||||
smooth = true
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private def loadImage(key: String): Image =
|
private def loadImage(key: String): Option[Image] =
|
||||||
val path = s"/sprites/pieces/$key.png"
|
val path = s"/sprites/pieces/$key.png"
|
||||||
Option(getClass.getResourceAsStream(path)) match
|
Option(getClass.getResourceAsStream(path)).map(new Image(_))
|
||||||
case Some(stream) => new Image(stream)
|
|
||||||
case None => sys.error(s"Could not load sprite: $path")
|
|
||||||
|
|
||||||
/** Get square colors for the board using theme. */
|
/** Get square colors for the board using theme. */
|
||||||
object SquareColors:
|
object SquareColors:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package de.nowchess.ui.terminal
|
package de.nowchess.ui.terminal
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import scala.io.StdIn
|
import scala.io.StdIn
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.api.game.DrawReason
|
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.ui.utils.Renderer
|
import de.nowchess.ui.utils.Renderer
|
||||||
@@ -90,7 +88,6 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
print(Renderer.render(engine.board))
|
print(Renderer.render(engine.board))
|
||||||
printPrompt(engine.turn)
|
printPrompt(engine.turn)
|
||||||
|
|
||||||
// Game loop
|
|
||||||
while running.get() do
|
while running.get() do
|
||||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||||
synchronized {
|
synchronized {
|
||||||
|
|||||||
Reference in New Issue
Block a user