refactor(core): NCS-22 update GameEngine to use GameContext and integrate Rule module
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, Board, Color, Piece}
|
import de.nowchess.api.board.{Square, Piece}
|
||||||
import de.nowchess.api.game.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Marker trait for all commands that can be executed and undone.
|
/** Marker trait for all commands that can be executed and undone.
|
||||||
* Commands encapsulate user actions and game state transitions.
|
* Commands encapsulate user actions and game state transitions.
|
||||||
@@ -23,23 +23,22 @@ case class MoveCommand(
|
|||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveResult: Option[MoveResult] = None,
|
moveResult: Option[MoveResult] = None,
|
||||||
previousBoard: Option[Board] = None,
|
previousContext: Option[GameContext] = None,
|
||||||
previousHistory: Option[GameHistory] = None,
|
notation: String = ""
|
||||||
previousTurn: Option[Color] = None
|
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean =
|
override def execute(): Boolean =
|
||||||
moveResult.isDefined
|
moveResult.isDefined
|
||||||
|
|
||||||
override def undo(): Boolean =
|
override def undo(): Boolean =
|
||||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
previousContext.isDefined
|
||||||
|
|
||||||
override def description: String = s"Move from $from to $to"
|
override def description: String = s"Move from $from to $to"
|
||||||
|
|
||||||
// Sealed hierarchy of move outcomes (for tracking state changes)
|
// Sealed hierarchy of move outcomes (for tracking state changes)
|
||||||
sealed trait MoveResult
|
sealed trait MoveResult
|
||||||
object MoveResult:
|
object MoveResult:
|
||||||
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult
|
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||||
case object InvalidFormat extends MoveResult
|
case object InvalidFormat extends MoveResult
|
||||||
case object InvalidMove extends MoveResult
|
case object InvalidMove extends MoveResult
|
||||||
|
|
||||||
@@ -51,14 +50,12 @@ case class QuitCommand() extends Command:
|
|||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** Command to reset the board to initial position. */
|
||||||
case class ResetCommand(
|
case class ResetCommand(
|
||||||
previousBoard: Option[Board] = None,
|
previousContext: Option[GameContext] = None
|
||||||
previousHistory: Option[GameHistory] = None,
|
|
||||||
previousTurn: Option[Color] = None
|
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
|
|
||||||
override def undo(): Boolean =
|
override def undo(): Boolean =
|
||||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
previousContext.isDefined
|
||||||
|
|
||||||
override def description: String = "Reset board"
|
override def description: String = "Reset board"
|
||||||
|
|||||||
@@ -1,57 +1,36 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Piece, Square, CastlingRights}
|
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||||
import de.nowchess.api.move.{PromotionPiece, Move}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.api.game.{GameHistory, GameContext}
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||||
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
|
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
|
||||||
|
import de.nowchess.rules.{RuleSet, StandardRules}
|
||||||
|
|
||||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
/** Pure game engine that manages game state and notifies observers of state changes.
|
||||||
* This class is the single source of truth for the game state.
|
* All rule queries delegate to the injected RuleSet.
|
||||||
* All user interactions must go through this engine via Commands, and all state changes
|
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||||
* are communicated to observers via GameEvent notifications.
|
|
||||||
*/
|
*/
|
||||||
class GameEngine(
|
class GameEngine(
|
||||||
initialBoard: Board = Board.initial,
|
initialContext: GameContext = GameContext.initial,
|
||||||
initialHistory: GameHistory = GameHistory.empty,
|
ruleSet: RuleSet = StandardRules
|
||||||
initialTurn: Color = Color.White,
|
|
||||||
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
|
|
||||||
GameController.completePromotion
|
|
||||||
) extends Observable:
|
) extends Observable:
|
||||||
private var currentBoard: Board = initialBoard
|
private var currentContext: GameContext = initialContext
|
||||||
private var currentHistory: GameHistory = initialHistory
|
|
||||||
private var currentTurn: Color = initialTurn
|
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
/** Inner class for tracking pending promotion state */
|
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||||
private case class PendingPromotion(
|
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||||
from: Square, to: Square,
|
|
||||||
boardBefore: Board, historyBefore: GameHistory,
|
|
||||||
turn: Color
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Current pending promotion, if any */
|
|
||||||
private var pendingPromotion: Option[PendingPromotion] = None
|
private var pendingPromotion: Option[PendingPromotion] = None
|
||||||
|
|
||||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized { currentContext.board }
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def turn: Color = synchronized { currentContext.turn }
|
||||||
def turn: Color = synchronized { currentTurn }
|
def context: GameContext = synchronized { currentContext }
|
||||||
|
|
||||||
/** Create a GameContext from current state (for event creation). */
|
|
||||||
private def currentContext: GameContext = GameContext(
|
|
||||||
board = currentBoard,
|
|
||||||
turn = currentTurn,
|
|
||||||
castlingRights = CastlingRights.Initial, // TODO: derive from history
|
|
||||||
enPassantSquare = None, // TODO: derive from history
|
|
||||||
halfMoveClock = currentHistory.halfMoveClock,
|
|
||||||
moves = List.empty // TODO: convert history moves to api.move.Move
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||||
@@ -69,7 +48,6 @@ class GameEngine(
|
|||||||
val trimmed = rawInput.trim.toLowerCase
|
val trimmed = rawInput.trim.toLowerCase
|
||||||
trimmed match
|
trimmed match
|
||||||
case "quit" | "q" =>
|
case "quit" | "q" =>
|
||||||
// Client should handle quit logic; we just return
|
|
||||||
()
|
()
|
||||||
|
|
||||||
case "undo" =>
|
case "undo" =>
|
||||||
@@ -79,10 +57,8 @@ class GameEngine(
|
|||||||
performRedo()
|
performRedo()
|
||||||
|
|
||||||
case "draw" =>
|
case "draw" =>
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
if currentContext.halfMoveClock >= 100 then
|
||||||
currentBoard = Board.initial
|
currentContext = GameContext.initial
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(DrawClaimedEvent(currentContext))
|
notifyObservers(DrawClaimedEvent(currentContext))
|
||||||
else
|
else
|
||||||
@@ -92,11 +68,7 @@ class GameEngine(
|
|||||||
))
|
))
|
||||||
|
|
||||||
case "" =>
|
case "" =>
|
||||||
val event = InvalidMoveEvent(
|
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||||
currentContext,
|
|
||||||
"Please enter a valid move or command."
|
|
||||||
)
|
|
||||||
notifyObservers(event)
|
|
||||||
|
|
||||||
case moveInput =>
|
case moveInput =>
|
||||||
Parser.parseMove(moveInput) match
|
Parser.parseMove(moveInput) match
|
||||||
@@ -106,67 +78,35 @@ class GameEngine(
|
|||||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||||
))
|
))
|
||||||
case Some((from, to)) =>
|
case Some((from, to)) =>
|
||||||
handleParsedMove(from, to, moveInput)
|
handleParsedMove(from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
private def handleParsedMove(from: Square, to: Square): Unit =
|
||||||
val cmd = MoveCommand(
|
currentContext.board.pieceAt(from) match
|
||||||
from = from,
|
case None =>
|
||||||
to = to,
|
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
|
||||||
previousBoard = Some(currentBoard),
|
case Some(piece) if piece.color != currentContext.turn =>
|
||||||
previousHistory = Some(currentHistory),
|
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
|
||||||
previousTurn = Some(currentTurn)
|
case Some(piece) =>
|
||||||
)
|
val legal = ruleSet.legalMoves(currentContext, from)
|
||||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
// Find all legal moves going to `to`
|
||||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
val candidates = legal.filter(_.to == to)
|
||||||
handleFailedMove(moveInput)
|
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 move :: _ =>
|
||||||
|
executeMove(move)
|
||||||
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
private def isPromotionMove(piece: Piece, to: Square): Boolean =
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
piece.pieceType == PieceType.Pawn && {
|
||||||
invoker.execute(updatedCmd)
|
val promoRank = if piece.color == Color.White then 7 else 0
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
to.rank.ordinal == promoRank
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
}
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
|
||||||
notifyObservers(CheckDetectedEvent(currentContext))
|
|
||||||
if currentHistory.halfMoveClock >= 100 then
|
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(StalemateEvent(currentContext))
|
|
||||||
|
|
||||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
|
||||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
|
||||||
notifyObservers(PromotionRequiredEvent(currentContext, promFrom, promTo))
|
|
||||||
|
|
||||||
/** Undo the last move. */
|
|
||||||
def undo(): Unit = synchronized {
|
|
||||||
performUndo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redo the last undone move. */
|
|
||||||
def redo(): Unit = synchronized {
|
|
||||||
performRedo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply a player's promotion piece choice.
|
/** Apply a player's promotion piece choice.
|
||||||
* Must only be called when isPendingPromotion is true.
|
* Must only be called when isPendingPromotion is true.
|
||||||
@@ -177,88 +117,48 @@ class GameEngine(
|
|||||||
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
|
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
|
||||||
case Some(pending) =>
|
case Some(pending) =>
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
val cmd = MoveCommand(
|
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||||
from = pending.from,
|
// Verify it's actually legal
|
||||||
to = pending.to,
|
val legal = ruleSet.legalMoves(currentContext, pending.from)
|
||||||
previousBoard = Some(pending.boardBefore),
|
if legal.contains(move) then
|
||||||
previousHistory = Some(pending.historyBefore),
|
executeMove(move)
|
||||||
previousTurn = Some(pending.turn)
|
else
|
||||||
)
|
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||||
completePromotionFn(
|
|
||||||
pending.boardBefore, pending.historyBefore,
|
|
||||||
pending.from, pending.to, piece, pending.turn
|
|
||||||
) match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
|
||||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
||||||
notifyObservers(CheckDetectedEvent(currentContext))
|
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
||||||
invoker.execute(updatedCmd)
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
notifyObservers(StalemateEvent(currentContext))
|
|
||||||
|
|
||||||
case _ =>
|
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo the last move. */
|
||||||
|
def undo(): Unit = synchronized { performUndo() }
|
||||||
|
|
||||||
|
/** Redo the last undone move. */
|
||||||
|
def redo(): Unit = synchronized { performRedo() }
|
||||||
|
|
||||||
/** Validate and load a PGN string.
|
/** Validate and load a PGN string.
|
||||||
* Each move is replayed through the command system so undo/redo is available after loading.
|
* Each move is replayed through the command system so undo/redo is available after loading.
|
||||||
* Returns Right(()) on success; Left(error) if any move is illegal or the position impossible. */
|
*/
|
||||||
def loadPgn(pgn: String): Either[String, Unit] = synchronized {
|
def loadPgn(pgn: String): Either[String, Unit] = synchronized {
|
||||||
PgnParser.validatePgn(pgn) match
|
PgnParser.validatePgn(pgn) match
|
||||||
case Left(err) =>
|
case Left(err) => Left(err)
|
||||||
Left(err)
|
|
||||||
case Right(game) =>
|
case Right(game) =>
|
||||||
val initialBoardBeforeLoad = currentBoard
|
val savedContext = currentContext
|
||||||
val initialHistoryBeforeLoad = currentHistory
|
currentContext = GameContext.initial
|
||||||
val initialTurnBeforeLoad = currentTurn
|
|
||||||
|
|
||||||
currentBoard = Board.initial
|
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
|
|
||||||
var error: Option[String] = None
|
var error: Option[String] = None
|
||||||
import scala.util.control.Breaks._
|
import scala.util.control.Breaks._
|
||||||
breakable {
|
breakable {
|
||||||
game.moves.foreach { move =>
|
game.moves.foreach { histMove =>
|
||||||
handleParsedMove(move.from, move.to, s"${move.from}${move.to}")
|
handleParsedMove(histMove.from, histMove.to)
|
||||||
move.promotionPiece.foreach(completePromotion)
|
histMove.promotionPiece.foreach(completePromotion)
|
||||||
|
if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then
|
||||||
// If the move failed to execute properly, stop and report
|
error = Some(s"Promotion required for move ${histMove.from}${histMove.to}")
|
||||||
// (validatePgn should have caught this, but we're being safe)
|
|
||||||
if pendingPromotion.isDefined && move.promotionPiece.isEmpty then
|
|
||||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
|
||||||
break()
|
break()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
error match
|
error match
|
||||||
case Some(err) =>
|
case Some(err) =>
|
||||||
currentBoard = initialBoardBeforeLoad
|
currentContext = savedContext
|
||||||
currentHistory = initialHistoryBeforeLoad
|
|
||||||
currentTurn = initialTurnBeforeLoad
|
|
||||||
Left(err)
|
Left(err)
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(PgnLoadedEvent(currentContext))
|
notifyObservers(PgnLoadedEvent(currentContext))
|
||||||
@@ -266,10 +166,8 @@ 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(board: Board, history: GameHistory, turn: Color): Unit = synchronized {
|
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||||
currentBoard = board
|
currentContext = newContext
|
||||||
currentHistory = history
|
|
||||||
currentTurn = turn
|
|
||||||
pendingPromotion = None
|
pendingPromotion = None
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(currentContext))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
@@ -277,28 +175,97 @@ class GameEngine(
|
|||||||
|
|
||||||
/** Reset the board to initial position. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentContext = GameContext.initial
|
||||||
currentHistory = GameHistory.empty
|
|
||||||
currentTurn = Color.White
|
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
currentContext
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──── Private Helpers ────
|
// ──── Private helpers ────
|
||||||
|
|
||||||
|
private def executeMove(move: Move): Unit =
|
||||||
|
val contextBefore = currentContext
|
||||||
|
val nextContext = ruleSet.applyMove(currentContext, move)
|
||||||
|
val captured = computeCaptured(currentContext, move)
|
||||||
|
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = move.from,
|
||||||
|
to = move.to,
|
||||||
|
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||||
|
previousContext = Some(contextBefore),
|
||||||
|
notation = moveToPgn(move, contextBefore.board)
|
||||||
|
)
|
||||||
|
invoker.execute(cmd)
|
||||||
|
currentContext = nextContext
|
||||||
|
|
||||||
|
notifyObservers(MoveExecutedEvent(
|
||||||
|
currentContext,
|
||||||
|
move.from.toString,
|
||||||
|
move.to.toString,
|
||||||
|
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
|
))
|
||||||
|
|
||||||
|
if ruleSet.isCheckmate(currentContext) then
|
||||||
|
val winner = currentContext.turn.opposite
|
||||||
|
currentContext = GameContext.initial
|
||||||
|
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||||
|
else if ruleSet.isStalemate(currentContext) then
|
||||||
|
currentContext = GameContext.initial
|
||||||
|
notifyObservers(StalemateEvent(currentContext))
|
||||||
|
else if ruleSet.isCheck(currentContext) then
|
||||||
|
notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
|
|
||||||
|
if currentContext.halfMoveClock >= 100 then
|
||||||
|
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
|
|
||||||
|
private def moveToPgn(move: Move, boardBefore: Board): String =
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.CastleKingside => "O-O"
|
||||||
|
case MoveType.CastleQueenside => "O-O-O"
|
||||||
|
case MoveType.EnPassant =>
|
||||||
|
s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
|
case MoveType.Promotion(pp) =>
|
||||||
|
val ppChar = pp match
|
||||||
|
case PromotionPiece.Queen => "Q"
|
||||||
|
case PromotionPiece.Rook => "R"
|
||||||
|
case PromotionPiece.Bishop => "B"
|
||||||
|
case PromotionPiece.Knight => "N"
|
||||||
|
s"${move.to}=$ppChar"
|
||||||
|
case MoveType.Normal =>
|
||||||
|
val isCapture = boardBefore.pieceAt(move.to).isDefined
|
||||||
|
boardBefore.pieceAt(move.from).map(_.pieceType) match
|
||||||
|
case Some(PieceType.Pawn) =>
|
||||||
|
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
|
else move.to.toString
|
||||||
|
case Some(pt) =>
|
||||||
|
val letter = pt match
|
||||||
|
case PieceType.Knight => "N"
|
||||||
|
case PieceType.Bishop => "B"
|
||||||
|
case PieceType.Rook => "R"
|
||||||
|
case PieceType.Queen => "Q"
|
||||||
|
case PieceType.King => "K"
|
||||||
|
case _ => ""
|
||||||
|
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
|
||||||
|
case None => move.to.toString
|
||||||
|
|
||||||
|
private def computeCaptured(context: GameContext, move: Move): Option[Piece] =
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.EnPassant =>
|
||||||
|
// Captured pawn is on the same rank as the moving pawn, same file as destination
|
||||||
|
val capturedSquare = Square(move.to.file, move.from.rank)
|
||||||
|
context.board.pieceAt(capturedSquare)
|
||||||
|
case MoveType.CastleKingside | MoveType.CastleQueenside =>
|
||||||
|
None
|
||||||
|
case _ =>
|
||||||
|
context.board.pieceAt(move.to)
|
||||||
|
|
||||||
private def performUndo(): Unit =
|
private def performUndo(): Unit =
|
||||||
if invoker.canUndo then
|
if invoker.canUndo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||||
(cmd: @unchecked) match
|
(cmd: @unchecked) match
|
||||||
case moveCmd: MoveCommand =>
|
case moveCmd: MoveCommand =>
|
||||||
val notation = currentHistory.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
moveCmd.previousContext.foreach(currentContext = _)
|
||||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
|
||||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
|
||||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
notifyObservers(MoveUndoneEvent(currentContext, notation))
|
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||||
|
|
||||||
@@ -307,44 +274,16 @@ class GameEngine(
|
|||||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
||||||
(cmd: @unchecked) match
|
(cmd: @unchecked) match
|
||||||
case moveCmd: MoveCommand =>
|
case moveCmd: MoveCommand =>
|
||||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
|
||||||
updateGameState(nb, nh, nt)
|
currentContext = nextCtx
|
||||||
invoker.redo()
|
invoker.redo()
|
||||||
val notation = nh.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
|
||||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
notifyObservers(MoveRedoneEvent(currentContext, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc))
|
notifyObservers(MoveRedoneEvent(
|
||||||
|
currentContext,
|
||||||
|
moveCmd.notation,
|
||||||
|
moveCmd.from.toString,
|
||||||
|
moveCmd.to.toString,
|
||||||
|
capturedDesc
|
||||||
|
))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||||
|
|
||||||
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
|
|
||||||
currentBoard = newBoard
|
|
||||||
currentHistory = newHistory
|
|
||||||
currentTurn = newTurn
|
|
||||||
|
|
||||||
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
|
|
||||||
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
|
||||||
notifyObservers(MoveExecutedEvent(
|
|
||||||
currentContext,
|
|
||||||
fromSq,
|
|
||||||
toSq,
|
|
||||||
capturedDesc
|
|
||||||
))
|
|
||||||
|
|
||||||
private def handleFailedMove(moveInput: String): Unit =
|
|
||||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
|
||||||
case MoveResult.NoPiece =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentContext,
|
|
||||||
"No piece on that square."
|
|
||||||
))
|
|
||||||
case MoveResult.WrongColor =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentContext,
|
|
||||||
"That is not your piece."
|
|
||||||
))
|
|
||||||
case MoveResult.IllegalMove =>
|
|
||||||
notifyObservers(InvalidMoveEvent(
|
|
||||||
currentContext,
|
|
||||||
"Illegal move."
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.api.game.{GameHistory, HistoryMove}
|
import de.nowchess.api.game.{GameContext, HistoryMove}
|
||||||
|
import de.nowchess.rules.StandardRules
|
||||||
|
|
||||||
/** A parsed PGN game containing headers and the resolved move list. */
|
/** A parsed PGN game containing headers and the resolved move list. */
|
||||||
case class PgnGame(
|
case class PgnGame(
|
||||||
@@ -27,11 +28,9 @@ object PgnParser:
|
|||||||
def parsePgn(pgn: String): Option[PgnGame] =
|
def parsePgn(pgn: String): Option[PgnGame] =
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
val lines = pgn.split("\n").map(_.trim)
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||||
|
|
||||||
val headers = parseHeaders(headerLines)
|
val headers = parseHeaders(headerLines)
|
||||||
val moveText = rest.mkString(" ")
|
val moveText = rest.mkString(" ")
|
||||||
val moves = parseMovesText(moveText)
|
val moves = parseMovesText(moveText)
|
||||||
|
|
||||||
Some(PgnGame(headers, moves))
|
Some(PgnGame(headers, moves))
|
||||||
|
|
||||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||||
@@ -42,40 +41,32 @@ object PgnParser:
|
|||||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
|
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
|
||||||
private def parseMovesText(moveText: String): List[HistoryMove] =
|
private def parseMovesText(moveText: String): List[HistoryMove] =
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||||
|
val (_, _, moves) = tokens.foldLeft(
|
||||||
// Fold over tokens, threading (board, history, currentColor, accumulator)
|
(GameContext.initial, Color.White, List.empty[HistoryMove])
|
||||||
val (_, _, _, moves) = tokens.foldLeft(
|
|
||||||
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
|
|
||||||
):
|
):
|
||||||
case (state @ (board, history, color, acc), token) =>
|
case (state @ (ctx, color, acc), token) =>
|
||||||
// Skip move-number markers (e.g. "1.", "2.") and result tokens
|
|
||||||
if isMoveNumberOrResult(token) then state
|
if isMoveNumberOrResult(token) then state
|
||||||
else
|
else
|
||||||
parseAlgebraicMove(token, board, history, color) match
|
parseAlgebraicMove(token, ctx, color) match
|
||||||
case None => state // unrecognised token — skip silently
|
case None => state
|
||||||
case Some(move) =>
|
case Some(move) =>
|
||||||
val newBoard = applyMoveToBoard(board, move, color)
|
val histMove = toHistoryMove(move, ctx, color)
|
||||||
val newHistory = history.addMove(move)
|
val nextCtx = StandardRules.applyMove(ctx, move)
|
||||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
(nextCtx, color.opposite, acc :+ histMove)
|
||||||
|
|
||||||
moves
|
moves
|
||||||
|
|
||||||
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
|
/** Convert an api.move.Move to a HistoryMove given the board context before the move. */
|
||||||
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
|
private def toHistoryMove(move: Move, ctx: GameContext, color: Color): HistoryMove =
|
||||||
move.castleSide match
|
val castleSide = move.moveType match
|
||||||
case Some("Kingside") => board.withCastle(color, CastleSide.Kingside)
|
case MoveType.CastleKingside => Some("Kingside")
|
||||||
case Some("Queenside") => board.withCastle(color, CastleSide.Queenside)
|
case MoveType.CastleQueenside => Some("Queenside")
|
||||||
case _ =>
|
case _ => None
|
||||||
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
val promotionPiece = move.moveType match
|
||||||
move.promotionPiece match
|
case MoveType.Promotion(pp) => Some(pp)
|
||||||
case Some(pp) =>
|
case _ => None
|
||||||
val pieceType = pp match
|
val pieceType = ctx.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn)
|
||||||
case PromotionPiece.Queen => PieceType.Queen
|
val isCapture = ctx.board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant
|
||||||
case PromotionPiece.Rook => PieceType.Rook
|
HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture)
|
||||||
case PromotionPiece.Bishop => PieceType.Bishop
|
|
||||||
case PromotionPiece.Knight => PieceType.Knight
|
|
||||||
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
|
||||||
case None => boardAfterMove
|
|
||||||
|
|
||||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||||
private def isMoveNumberOrResult(token: String): Boolean =
|
private def isMoveNumberOrResult(token: String): Boolean =
|
||||||
@@ -85,85 +76,74 @@ object PgnParser:
|
|||||||
token == "0-1" ||
|
token == "0-1" ||
|
||||||
token == "1/2-1/2"
|
token == "1/2-1/2"
|
||||||
|
|
||||||
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
|
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||||
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||||
notation match
|
notation match
|
||||||
case "O-O" | "O-O+" | "O-O#" =>
|
case "O-O" | "O-O+" | "O-O#" =>
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some("Kingside"), pieceType = PieceType.King))
|
val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside)
|
||||||
|
Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move)
|
||||||
|
|
||||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some("Queenside"), pieceType = PieceType.King))
|
val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside)
|
||||||
|
Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move)
|
||||||
|
|
||||||
case _ =>
|
case _ =>
|
||||||
parseRegularMove(notation, board, history, color)
|
parseRegularMove(notation, ctx, color)
|
||||||
|
|
||||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
||||||
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
private def parseRegularMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||||
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
|
|
||||||
val clean = notation
|
val clean = notation
|
||||||
.replace("+", "")
|
.replace("+", "")
|
||||||
.replace("#", "")
|
.replace("#", "")
|
||||||
.replace("x", "")
|
.replace("x", "")
|
||||||
.replaceAll("=[NBRQ]$", "")
|
.replaceAll("=[NBRQ]$", "")
|
||||||
|
|
||||||
// The destination square is always the last two characters
|
|
||||||
if clean.length < 2 then None
|
if clean.length < 2 then None
|
||||||
else
|
else
|
||||||
val destStr = clean.takeRight(2)
|
val destStr = clean.takeRight(2)
|
||||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||||
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
|
val disambig = clean.dropRight(2)
|
||||||
|
|
||||||
// Determine required piece type: upper-case first char = piece letter; else pawn
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
val requiredPieceType: Option[PieceType] =
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||||
else Some(PieceType.Pawn)
|
else Some(PieceType.Pawn)
|
||||||
|
|
||||||
// Collect the disambiguation hint that remains after stripping the piece letter
|
|
||||||
val hint =
|
val hint =
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||||
else disambig // hint is file/rank info or empty
|
else disambig
|
||||||
|
|
||||||
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
|
val promotion = extractPromotion(notation)
|
||||||
// We prefer pieces that can actually reach the target; if none can (positionally illegal
|
|
||||||
// PGN input), fall back to any piece of the matching type belonging to `color`.
|
|
||||||
val reachable: Set[Square] =
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color &&
|
|
||||||
MoveValidator.legalTargets(board, from).contains(toSquare) => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
val candidates: Set[Square] =
|
// Get all legal moves for this color that reach toSquare
|
||||||
if reachable.nonEmpty then reachable
|
val allLegal = StandardRules.allLegalMoves(ctx)
|
||||||
else
|
val candidates = allLegal.filter { move =>
|
||||||
// Fallback for positionally-illegal but syntactically valid PGN notation:
|
move.to == toSquare &&
|
||||||
// find any piece of `color` with the correct piece type on the board.
|
ctx.board.pieceAt(move.from).exists(p =>
|
||||||
board.pieces.collect {
|
p.color == color &&
|
||||||
case (from, piece) if piece.color == color => from
|
requiredPieceType.forall(_ == p.pieceType)
|
||||||
}.toSet
|
) &&
|
||||||
|
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||||
|
promotionMatches(move, promotion)
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by required piece type
|
candidates.headOption
|
||||||
val byPiece = candidates.filter(from =>
|
|
||||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Apply disambiguation hint (file letter or rank digit)
|
|
||||||
val disambiguated =
|
|
||||||
if hint.isEmpty then byPiece
|
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
|
||||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
|
||||||
val moveIsCapture = notation.contains('x')
|
|
||||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
|
||||||
|
|
||||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||||
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
hint.forall(c =>
|
||||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||||
else true)
|
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||||
|
else true
|
||||||
|
)
|
||||||
|
|
||||||
|
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||||
|
promotion match
|
||||||
|
case None => move.moveType == MoveType.Normal || move.moveType == MoveType.EnPassant ||
|
||||||
|
move.moveType == MoveType.CastleKingside || move.moveType == MoveType.CastleQueenside
|
||||||
|
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||||
|
|
||||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||||
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
||||||
@@ -192,77 +172,16 @@ object PgnParser:
|
|||||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||||
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||||
tokens.foldLeft(Right((Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])): Either[String, (Board, GameHistory, Color, List[HistoryMove])]) {
|
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[HistoryMove])): Either[String, (GameContext, Color, List[HistoryMove])]) {
|
||||||
case (acc, token) =>
|
case (acc, token) =>
|
||||||
acc.flatMap { case (board, history, color, moves) =>
|
acc.flatMap { case (ctx, color, moves) =>
|
||||||
if isMoveNumberOrResult(token) then Right((board, history, color, moves))
|
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||||
else
|
else
|
||||||
strictParseAlgebraicMove(token, board, history, color) match
|
parseAlgebraicMove(token, ctx, color) match
|
||||||
case None => Left(s"Illegal or impossible move: '$token'")
|
case None => Left(s"Illegal or impossible move: '$token'")
|
||||||
case Some(move) =>
|
case Some(move) =>
|
||||||
val newBoard = applyMoveToBoard(board, move, color)
|
val histMove = toHistoryMove(move, ctx, color)
|
||||||
val newHistory = history.addMove(move)
|
val nextCtx = StandardRules.applyMove(ctx, move)
|
||||||
Right((newBoard, newHistory, color.opposite, moves :+ move))
|
Right((nextCtx, color.opposite, moves :+ histMove))
|
||||||
}
|
}
|
||||||
}.map(_._4)
|
}.map(_._3)
|
||||||
|
|
||||||
/** Strict algebraic move parse — no fallback to positionally-illegal moves. */
|
|
||||||
private def strictParseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
|
||||||
notation match
|
|
||||||
case "O-O" | "O-O+" | "O-O#" =>
|
|
||||||
val dest = Square(File.G, rank)
|
|
||||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
|
||||||
HistoryMove(Square(File.E, rank), dest, Some("Kingside"), pieceType = PieceType.King)
|
|
||||||
)
|
|
||||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
|
||||||
val dest = Square(File.C, rank)
|
|
||||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
|
||||||
HistoryMove(Square(File.E, rank), dest, Some("Queenside"), pieceType = PieceType.King)
|
|
||||||
)
|
|
||||||
case _ =>
|
|
||||||
strictParseRegularMove(notation, board, history, color)
|
|
||||||
|
|
||||||
/** Strict regular move parse — uses only legally reachable squares, no fallback. */
|
|
||||||
private def strictParseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
|
||||||
val clean = notation
|
|
||||||
.replace("+", "")
|
|
||||||
.replace("#", "")
|
|
||||||
.replace("x", "")
|
|
||||||
.replaceAll("=[NBRQ]$", "")
|
|
||||||
|
|
||||||
if clean.length < 2 then None
|
|
||||||
else
|
|
||||||
val destStr = clean.takeRight(2)
|
|
||||||
Square.fromAlgebraic(destStr).flatMap { toSquare =>
|
|
||||||
val disambig = clean.dropRight(2)
|
|
||||||
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
|
||||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
|
||||||
else Some(PieceType.Pawn)
|
|
||||||
|
|
||||||
val hint =
|
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
|
||||||
else disambig
|
|
||||||
|
|
||||||
// Strict: only squares from which a legal move (including en passant/castling awareness) exists.
|
|
||||||
val reachable: Set[Square] =
|
|
||||||
board.pieces.collect {
|
|
||||||
case (from, piece) if piece.color == color &&
|
|
||||||
MoveValidator.legalTargets(board, history, from).contains(toSquare) => from
|
|
||||||
}.toSet
|
|
||||||
|
|
||||||
val byPiece = reachable.filter(from =>
|
|
||||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
|
||||||
)
|
|
||||||
|
|
||||||
val disambiguated =
|
|
||||||
if hint.isEmpty then byPiece
|
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
|
||||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
|
||||||
val moveIsCapture = notation.contains('x')
|
|
||||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
|
||||||
}
|
|
||||||
|
|||||||
+12
-66
@@ -1,38 +1,32 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
// ──── Helper: Command that always fails ────
|
|
||||||
private case class FailingCommand() extends Command:
|
private case class FailingCommand() extends Command:
|
||||||
override def execute(): Boolean = false
|
override def execute(): Boolean = false
|
||||||
override def undo(): Boolean = false
|
override def undo(): Boolean = false
|
||||||
override def description: String = "Failing command"
|
override def description: String = "Failing command"
|
||||||
|
|
||||||
// ──── Helper: Command that conditionally fails on undo or execute ────
|
|
||||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
||||||
override def execute(): Boolean = !shouldFailOnExecute
|
override def execute(): Boolean = !shouldFailOnExecute
|
||||||
override def undo(): Boolean = !shouldFailOnUndo
|
override def undo(): Boolean = !shouldFailOnUndo
|
||||||
override def description: String = "Conditional fail"
|
override def description: String = "Conditional fail"
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||||
val cmd = MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None,
|
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial)
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
cmd
|
|
||||||
|
|
||||||
// ──── BRANCH: execute() returns false ────
|
|
||||||
test("CommandInvoker.execute() with failing command returns false"):
|
test("CommandInvoker.execute() with failing command returns false"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = FailingCommand()
|
val cmd = FailingCommand()
|
||||||
@@ -44,18 +38,14 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val failingCmd = FailingCommand()
|
val failingCmd = FailingCommand()
|
||||||
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(failingCmd) shouldBe false
|
invoker.execute(failingCmd) shouldBe false
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
|
|
||||||
invoker.execute(successCmd) shouldBe true
|
invoker.execute(successCmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.history(0) shouldBe successCmd
|
invoker.history(0) shouldBe successCmd
|
||||||
|
|
||||||
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ────
|
|
||||||
test("CommandInvoker.undo() returns false when currentIndex < 0"):
|
test("CommandInvoker.undo() returns false when currentIndex < 0"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
// currentIndex starts at -1
|
|
||||||
invoker.undo() shouldBe false
|
invoker.undo() shouldBe false
|
||||||
|
|
||||||
test("CommandInvoker.undo() returns false when empty history"):
|
test("CommandInvoker.undo() returns false when empty history"):
|
||||||
@@ -63,41 +53,31 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.canUndo shouldBe false
|
invoker.canUndo shouldBe false
|
||||||
invoker.undo() shouldBe false
|
invoker.undo() shouldBe false
|
||||||
|
|
||||||
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ────
|
|
||||||
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
|
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
// currentIndex now = 1, history.size = 2
|
invoker.undo()
|
||||||
|
invoker.undo()
|
||||||
invoker.undo() // currentIndex becomes 0
|
invoker.undo()
|
||||||
invoker.undo() // currentIndex becomes -1
|
|
||||||
invoker.undo() // currentIndex still -1, should fail
|
|
||||||
|
|
||||||
// ──── BRANCH: undo() command returns false ────
|
|
||||||
test("CommandInvoker.undo() returns false when command.undo() fails"):
|
test("CommandInvoker.undo() returns false when command.undo() fails"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||||
|
|
||||||
invoker.execute(failingCmd) shouldBe true
|
invoker.execute(failingCmd) shouldBe true
|
||||||
invoker.canUndo shouldBe true
|
invoker.canUndo shouldBe true
|
||||||
|
|
||||||
invoker.undo() shouldBe false
|
invoker.undo() shouldBe false
|
||||||
// Index should not change when undo fails
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
|
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
|
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
|
||||||
|
|
||||||
invoker.execute(successCmd) shouldBe true
|
invoker.execute(successCmd) shouldBe true
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
|
|
||||||
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ────
|
|
||||||
test("CommandInvoker.redo() returns false when nothing to redo"):
|
test("CommandInvoker.redo() returns false when nothing to redo"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
@@ -105,9 +85,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
test("CommandInvoker.redo() returns false when at end of history"):
|
test("CommandInvoker.redo() returns false when at end of history"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
// currentIndex = 0, history.size = 1
|
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
|
|
||||||
@@ -115,59 +93,41 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false
|
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
|
|
||||||
// ──── BRANCH: redo() command returns false ────
|
|
||||||
test("CommandInvoker.redo() returns false when command.execute() fails"):
|
test("CommandInvoker.redo() returns false when command.execute() fails"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute
|
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false)
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(redoFailCmd) // Succeeds and added to history
|
invoker.execute(redoFailCmd)
|
||||||
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// currentIndex = 0, redoFailCmd is at index 1
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
// Now modify to fail on next execute (redo)
|
|
||||||
redoFailCmd.shouldFailOnExecute = true
|
redoFailCmd.shouldFailOnExecute = true
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
// currentIndex should not change
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
|
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd) shouldBe true
|
invoker.execute(cmd) shouldBe true
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.redo() shouldBe true
|
invoker.redo() shouldBe true
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
// ──── BRANCH: execute() with redo history discarding (while loop) ────
|
|
||||||
test("CommandInvoker.execute() discards redo history via while loop"):
|
test("CommandInvoker.execute() discards redo history via while loop"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
// currentIndex = 1, size = 2
|
|
||||||
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// currentIndex = 0, size = 2
|
|
||||||
// Redo history exists: cmd2 is at index 1
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
// while loop should discard cmd2
|
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.history(1) shouldBe cmd3
|
invoker.history(1) shouldBe cmd3
|
||||||
@@ -178,39 +138,25 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
invoker.execute(cmd4)
|
invoker.execute(cmd4)
|
||||||
// currentIndex = 3, size = 4
|
|
||||||
|
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// currentIndex = 1, size = 4
|
|
||||||
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
|
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
||||||
invoker.execute(newCmd)
|
invoker.execute(newCmd)
|
||||||
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
|
|
||||||
invoker.history.size shouldBe 3
|
invoker.history.size shouldBe 3
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
|
|
||||||
// ──── BRANCH: execute() with no redo history to discard ────
|
|
||||||
test("CommandInvoker.execute() with no redo history (while condition false)"):
|
test("CommandInvoker.execute() with no redo history (while condition false)"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
|
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
// currentIndex = 1, size = 2
|
|
||||||
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
|
|
||||||
|
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
invoker.execute(cmd3)
|
||||||
invoker.history.size shouldBe 3
|
invoker.history.size shouldBe 3
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
||||||
MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial)
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
test("CommandInvoker executes a command and adds it to history"):
|
test("CommandInvoker executes a command and adds it to history"):
|
||||||
@@ -92,10 +90,8 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// After undoing twice, we're at the beginning (before any commands)
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
// Executing a new command from the beginning discards all redo history
|
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
@@ -110,14 +106,11 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
// After one undo, we're at the end of cmd1
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
// Executing a new command discards cmd2 (the redo history)
|
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
invoker.history(0) shouldBe cmd1
|
invoker.history(0) shouldBe cmd1
|
||||||
invoker.history(1) shouldBe cmd3
|
invoker.history(1) shouldBe cmd3
|
||||||
invoker.getCurrentIndex shouldBe 1
|
invoker.getCurrentIndex shouldBe 1
|
||||||
|
|
||||||
|
|||||||
+8
-21
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
@@ -14,10 +14,8 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
MoveCommand(
|
MoveCommand(
|
||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial)
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
||||||
@@ -25,15 +23,11 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
@volatile var raceDetected = false
|
@volatile var raceDetected = false
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
val exceptions = mutable.ListBuffer[Exception]()
|
||||||
|
|
||||||
// Thread 1: executes commands
|
|
||||||
val executorThread = new Thread(new Runnable {
|
val executorThread = new Thread(new Runnable {
|
||||||
def run(): Unit = {
|
def run(): Unit = {
|
||||||
try {
|
try {
|
||||||
for i <- 1 to 1000 do
|
for i <- 1 to 1000 do
|
||||||
val cmd = createMoveCommand(
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
sq(File.E, Rank.R2),
|
|
||||||
sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
} catch {
|
} catch {
|
||||||
case e: Exception =>
|
case e: Exception =>
|
||||||
@@ -43,14 +37,13 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thread 2: reads history during execution
|
|
||||||
val readerThread = new Thread(new Runnable {
|
val readerThread = new Thread(new Runnable {
|
||||||
def run(): Unit = {
|
def run(): Unit = {
|
||||||
try {
|
try {
|
||||||
for _ <- 1 to 1000 do
|
for _ <- 1 to 1000 do
|
||||||
val _ = invoker.history
|
val _ = invoker.history
|
||||||
val _ = invoker.getCurrentIndex
|
val _ = invoker.getCurrentIndex
|
||||||
Thread.sleep(0) // Yield to increase contention
|
Thread.sleep(0)
|
||||||
} catch {
|
} catch {
|
||||||
case e: Exception =>
|
case e: Exception =>
|
||||||
exceptions += e
|
exceptions += e
|
||||||
@@ -72,11 +65,9 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
@volatile var raceDetected = false
|
@volatile var raceDetected = false
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
val exceptions = mutable.ListBuffer[Exception]()
|
||||||
|
|
||||||
// Pre-populate with some commands
|
|
||||||
for _ <- 1 to 5 do
|
for _ <- 1 to 5 do
|
||||||
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||||
|
|
||||||
// Thread 1: executes new commands
|
|
||||||
val executorThread = new Thread(new Runnable {
|
val executorThread = new Thread(new Runnable {
|
||||||
def run(): Unit = {
|
def run(): Unit = {
|
||||||
try {
|
try {
|
||||||
@@ -90,13 +81,11 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thread 2: undoes commands
|
|
||||||
val undoThread = new Thread(new Runnable {
|
val undoThread = new Thread(new Runnable {
|
||||||
def run(): Unit = {
|
def run(): Unit = {
|
||||||
try {
|
try {
|
||||||
for _ <- 1 to 500 do
|
for _ <- 1 to 500 do
|
||||||
if invoker.canUndo then
|
if invoker.canUndo then invoker.undo()
|
||||||
invoker.undo()
|
|
||||||
} catch {
|
} catch {
|
||||||
case e: Exception =>
|
case e: Exception =>
|
||||||
exceptions += e
|
exceptions += e
|
||||||
@@ -105,13 +94,11 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thread 3: redoes commands
|
|
||||||
val redoThread = new Thread(new Runnable {
|
val redoThread = new Thread(new Runnable {
|
||||||
def run(): Unit = {
|
def run(): Unit = {
|
||||||
try {
|
try {
|
||||||
for _ <- 1 to 500 do
|
for _ <- 1 to 500 do
|
||||||
if invoker.canRedo then
|
if invoker.canRedo then invoker.redo()
|
||||||
invoker.redo()
|
|
||||||
} catch {
|
} catch {
|
||||||
case e: Exception =>
|
case e: Exception =>
|
||||||
exceptions += e
|
exceptions += e
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -29,24 +28,15 @@ class CommandTest extends AnyFunSuite with Matchers:
|
|||||||
cmd.undo() shouldBe false
|
cmd.undo() shouldBe false
|
||||||
|
|
||||||
test("ResetCommand with prior state can undo"):
|
test("ResetCommand with prior state can undo"):
|
||||||
val cmd = ResetCommand(
|
val cmd = ResetCommand(previousContext = Some(GameContext.initial))
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
cmd.execute() shouldBe true
|
||||||
cmd.undo() shouldBe true
|
cmd.undo() shouldBe true
|
||||||
|
|
||||||
test("ResetCommand with partial state cannot undo"):
|
test("ResetCommand with no context cannot undo"):
|
||||||
val cmd = ResetCommand(
|
val cmd = ResetCommand(previousContext = None)
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = None, // missing
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
cmd.execute() shouldBe true
|
||||||
cmd.undo() shouldBe false
|
cmd.undo() shouldBe false
|
||||||
|
|
||||||
test("ResetCommand description"):
|
test("ResetCommand description"):
|
||||||
val cmd = ResetCommand()
|
val cmd = ResetCommand()
|
||||||
cmd.description shouldBe "Reset board"
|
cmd.description shouldBe "Reset board"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class MoveCommandDefaultsTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
|
test("MoveCommand with no moveResult defaults to None"):
|
||||||
|
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
cmd.moveResult shouldBe None
|
||||||
|
cmd.execute() shouldBe false
|
||||||
|
|
||||||
|
test("MoveCommand with no previousContext defaults to None"):
|
||||||
|
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
cmd.previousContext shouldBe None
|
||||||
|
cmd.undo() shouldBe false
|
||||||
|
|
||||||
|
test("MoveCommand description is always returned"):
|
||||||
|
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
|
cmd.description shouldBe "Move from e2 to e4"
|
||||||
|
|
||||||
|
test("MoveCommand execute returns false when moveResult is None"):
|
||||||
|
val cmd = MoveCommand(from = sq(File.A, Rank.R1), to = sq(File.B, Rank.R3))
|
||||||
|
cmd.execute() shouldBe false
|
||||||
|
|
||||||
|
test("MoveCommand undo returns false when previousContext is None"):
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
|
previousContext = None
|
||||||
|
)
|
||||||
|
cmd.undo() shouldBe false
|
||||||
|
|
||||||
|
test("MoveCommand execute returns true when moveResult is defined"):
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
||||||
|
)
|
||||||
|
cmd.execute() shouldBe true
|
||||||
|
|
||||||
|
test("MoveCommand undo returns true when previousContext is defined"):
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = sq(File.E, Rank.R2),
|
||||||
|
to = sq(File.E, Rank.R4),
|
||||||
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
|
previousContext = Some(GameContext.initial)
|
||||||
|
)
|
||||||
|
cmd.undo() shouldBe true
|
||||||
+9
-27
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
import de.nowchess.api.board.{Square, File, Rank}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -10,56 +10,38 @@ class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
|
|||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
|
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
|
||||||
val cmd1 = MoveCommand(
|
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create second command with filled state
|
val result = MoveResult.Successful(GameContext.initial, None)
|
||||||
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
|
|
||||||
val cmd2 = cmd1.copy(
|
val cmd2 = cmd1.copy(
|
||||||
moveResult = Some(result),
|
moveResult = Some(result),
|
||||||
previousBoard = Some(Board.initial),
|
previousContext = Some(GameContext.initial)
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Original should be unchanged
|
|
||||||
cmd1.moveResult shouldBe None
|
cmd1.moveResult shouldBe None
|
||||||
cmd1.previousBoard shouldBe None
|
cmd1.previousContext shouldBe None
|
||||||
cmd1.previousHistory shouldBe None
|
|
||||||
cmd1.previousTurn shouldBe None
|
|
||||||
|
|
||||||
// New should have values
|
|
||||||
cmd2.moveResult shouldBe Some(result)
|
cmd2.moveResult shouldBe Some(result)
|
||||||
cmd2.previousBoard shouldBe Some(Board.initial)
|
cmd2.previousContext shouldBe Some(GameContext.initial)
|
||||||
cmd2.previousHistory shouldBe Some(GameHistory.empty)
|
|
||||||
cmd2.previousTurn shouldBe Some(Color.White)
|
|
||||||
|
|
||||||
test("MoveCommand equals and hashCode respect immutability"):
|
test("MoveCommand equals and hashCode respect immutability"):
|
||||||
val cmd1 = MoveCommand(
|
val cmd1 = MoveCommand(
|
||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = None,
|
moveResult = None,
|
||||||
previousBoard = None,
|
previousContext = None
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val cmd2 = MoveCommand(
|
val cmd2 = MoveCommand(
|
||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = None,
|
moveResult = None,
|
||||||
previousBoard = None,
|
previousContext = None
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Same values should be equal
|
|
||||||
cmd1 shouldBe cmd2
|
cmd1 shouldBe cmd2
|
||||||
cmd1.hashCode shouldBe cmd2.hashCode
|
cmd1.hashCode shouldBe cmd2.hashCode
|
||||||
|
|
||||||
// Hash should be consistent (required for use as map keys)
|
|
||||||
val hash1 = cmd1.hashCode
|
val hash1 = cmd1.hashCode
|
||||||
val hash2 = cmd1.hashCode
|
val hash2 = cmd1.hashCode
|
||||||
hash1 shouldBe hash2
|
hash1 shouldBe hash2
|
||||||
|
|||||||
@@ -1,526 +0,0 @@
|
|||||||
package de.nowchess.chess.controller
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
|
||||||
import de.nowchess.chess.notation.FenParser
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class GameControllerTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
|
||||||
GameController.processMove(board, history, turn, raw)
|
|
||||||
|
|
||||||
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
|
|
||||||
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
|
|
||||||
|
|
||||||
// ──── processMove ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: 'quit' input returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: 'q' input returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: quit with surrounding whitespace returns Quit"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
|
|
||||||
|
|
||||||
test("processMove: unparseable input returns InvalidFormat"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
|
|
||||||
|
|
||||||
test("processMove: valid format but empty square returns NoPiece"):
|
|
||||||
// E3 is empty in the initial position
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
|
|
||||||
|
|
||||||
test("processMove: piece of wrong color returns WrongColor"):
|
|
||||||
// E7 has a Black pawn; it is White's turn
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
|
|
||||||
|
|
||||||
test("processMove: geometrically illegal move returns IllegalMove"):
|
|
||||||
// White pawn at E2 cannot jump three squares to E5
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: move that leaves own king in check returns IllegalMove"):
|
|
||||||
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
|
|
||||||
// geometrically legal but does not resolve the check — must be rejected.
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.D, Rank.R2) -> Piece.WhitePawn,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: move that resolves check is allowed"):
|
|
||||||
// White King E1 is in check from Black Rook E8 along the E-file.
|
|
||||||
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a5e5") match
|
|
||||||
case _: MoveResult.Moved => succeed
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
|
|
||||||
captured shouldBe None
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal capture returns Moved with the captured piece"):
|
|
||||||
val board = Board(Map(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(board, GameHistory.empty, Color.White, "e5d6") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
captured shouldBe Some(Piece.BlackPawn)
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
// ──── processMove: check / checkmate / stalemate ─────────────────────
|
|
||||||
|
|
||||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
|
||||||
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
|
|
||||||
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.C, Rank.R3) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a1a8") match
|
|
||||||
case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: legal move that results in checkmate returns Checkmate"):
|
|
||||||
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8)
|
|
||||||
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position)
|
|
||||||
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
|
|
||||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "a1h8") match
|
|
||||||
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
|
|
||||||
case other => fail(s"Expected Checkmate(White), got $other")
|
|
||||||
|
|
||||||
test("processMove: legal move that results in stalemate returns Stalemate"):
|
|
||||||
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
|
|
||||||
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position)
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
|
|
||||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "b1b6") match
|
|
||||||
case MoveResult.Stalemate => succeed
|
|
||||||
case other => fail(s"Expected Stalemate, got $other")
|
|
||||||
|
|
||||||
// ──── castling execution ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
||||||
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
|
||||||
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
|
|
||||||
captured shouldBe None
|
|
||||||
newTurn shouldBe Color.Black
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: e1c1 returns Moved with king on c1 and rook on d1"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1c1") match
|
|
||||||
case MoveResult.Moved(newBoard, _, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
// ──── rights revocation ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: e1g1 revokes both white castling rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from h1 revokes white kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "h1h4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving king from e1 revokes both white rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1e2") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: enemy capture on h1 revokes white kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: castle attempt when rights revoked returns IllegalMove"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
|
|
||||||
processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: castle attempt when rook not on home square returns IllegalMove"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.G, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
|
||||||
|
|
||||||
test("processMove: moving king from e8 revokes both black rights"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from a8 revokes black queenside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: moving rook from h8 revokes black kingside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
|
||||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
test("processMove: enemy capture on a1 revokes white queenside right"):
|
|
||||||
val b = Board(Map(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.A, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
|
||||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
|
||||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
|
||||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
|
||||||
|
|
||||||
// ──── en passant ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("en passant capture removes the captured pawn from the board"):
|
|
||||||
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
|
|
||||||
val b = Board(Map(
|
|
||||||
Square(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
Square(File.D, Rank.R5) -> Piece.BlackPawn,
|
|
||||||
Square(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
Square(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
|
|
||||||
val result = GameController.processMove(b, h, Color.White, "e5d6")
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
|
|
||||||
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
|
|
||||||
captured shouldBe Some(Piece.BlackPawn)
|
|
||||||
case other => fail(s"Expected Moved but got $other")
|
|
||||||
|
|
||||||
test("en passant capture by black removes the captured white pawn"):
|
|
||||||
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
|
|
||||||
val b = Board(Map(
|
|
||||||
Square(File.D, Rank.R4) -> Piece.BlackPawn,
|
|
||||||
Square(File.E, Rank.R4) -> Piece.WhitePawn,
|
|
||||||
Square(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
Square(File.E, Rank.R1) -> Piece.WhiteKing
|
|
||||||
))
|
|
||||||
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
|
||||||
val result = GameController.processMove(b, h, Color.Black, "d4e3")
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
|
|
||||||
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
|
|
||||||
captured shouldBe Some(Piece.WhitePawn)
|
|
||||||
case other => fail(s"Expected Moved but got $other")
|
|
||||||
|
|
||||||
// ──── pawn promotion detection ───────────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
||||||
from should be (sq(File.E, Rank.R7))
|
|
||||||
to should be (sq(File.E, Rank.R8))
|
|
||||||
turn should be (Color.White)
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
|
|
||||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
||||||
from should be (sq(File.E, Rank.R2))
|
|
||||||
to should be (sq(File.E, Rank.R1))
|
|
||||||
turn should be (Color.Black)
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
|
|
||||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
|
||||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
|
|
||||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
||||||
result match
|
|
||||||
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
|
|
||||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
// ──── completePromotion ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("completePromotion applies move and places queen"):
|
|
||||||
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case _: MoveResult.Moved => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
|
||||||
newHistory.moves should have length 1
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
|
|
||||||
test("completePromotion with rook underpromotion"):
|
|
||||||
// Black king on h1: not attacked by rook on e8 (different file and rank)
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Rook, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
|
||||||
case _ => fail("Expected Moved with Rook")
|
|
||||||
|
|
||||||
test("completePromotion with bishop underpromotion"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Bishop, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
|
|
||||||
case _ => fail("Expected Moved with Bishop")
|
|
||||||
|
|
||||||
test("completePromotion with knight underpromotion"):
|
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Knight, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
||||||
case _ => fail("Expected Moved with Knight")
|
|
||||||
|
|
||||||
test("completePromotion captures opponent piece"):
|
|
||||||
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
|
|
||||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
||||||
case _ => fail("Expected Moved with captured piece")
|
|
||||||
|
|
||||||
test("completePromotion for black pawn to R1"):
|
|
||||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
|
|
||||||
PromotionPiece.Knight, Color.Black
|
|
||||||
)
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
||||||
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
|
|
||||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
|
|
||||||
test("completePromotion evaluates check after promotion"):
|
|
||||||
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
|
||||||
|
|
||||||
test("completePromotion full round-trip via processMove then completePromotion"):
|
|
||||||
// Black king on h1: not attacked by queen on e8
|
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
|
||||||
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
|
|
||||||
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
|
|
||||||
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
|
|
||||||
result should matchPattern { case _: MoveResult.Moved => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
|
|
||||||
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
||||||
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
||||||
case _ => fail("Expected Moved")
|
|
||||||
case _ => fail("Expected PromotionRequired")
|
|
||||||
|
|
||||||
test("completePromotion results in checkmate when promotion delivers checkmate"):
|
|
||||||
// Black king a8, white pawn h7, white king b6.
|
|
||||||
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
|
|
||||||
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
|
|
||||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
|
|
||||||
PromotionPiece.Queen, Color.White
|
|
||||||
)
|
|
||||||
result should matchPattern { case MoveResult.Checkmate(_) => }
|
|
||||||
result match
|
|
||||||
case MoveResult.Checkmate(winner) => winner should be (Color.White)
|
|
||||||
case _ => fail("Expected Checkmate")
|
|
||||||
|
|
||||||
test("completePromotion results in stalemate when promotion stalemates opponent"):
|
|
||||||
// Black king a8, white pawn b7, white bishop c7, white king b6.
|
|
||||||
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
|
|
||||||
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
|
|
||||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
|
||||||
val result = GameController.completePromotion(
|
|
||||||
board, GameHistory.empty,
|
|
||||||
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
|
|
||||||
PromotionPiece.Knight, Color.White
|
|
||||||
)
|
|
||||||
result should be (MoveResult.Stalemate)
|
|
||||||
|
|
||||||
// ──── half-move clock propagation ────────────────────────────────────
|
|
||||||
|
|
||||||
test("processMove: non-pawn non-capture increments halfMoveClock"):
|
|
||||||
// g1f3 is a knight move — not a pawn, not a capture
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 1
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: pawn move resets halfMoveClock to 0"):
|
|
||||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 0
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: capture resets halfMoveClock to 0"):
|
|
||||||
// White pawn on e5, Black pawn on d6 — exd6 is a capture
|
|
||||||
val board = Board(Map(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
))
|
|
||||||
val history = GameHistory(halfMoveClock = 10)
|
|
||||||
processMove(board, history, Color.White, "e5d6") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 0
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
|
|
||||||
test("processMove: clock carries from previous history on non-pawn non-capture"):
|
|
||||||
val history = GameHistory(halfMoveClock = 5)
|
|
||||||
processMove(Board.initial, history, Color.White, "g1f3") match
|
|
||||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
|
||||||
newHistory.halfMoveClock shouldBe 6
|
|
||||||
case other => fail(s"Expected Moved, got $other")
|
|
||||||
@@ -2,7 +2,6 @@ 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.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
|
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -197,7 +196,6 @@ class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// Access from synchronized methods
|
// Access from synchronized methods
|
||||||
val board = engine.board
|
val board = engine.board
|
||||||
val history = engine.history
|
|
||||||
val turn = engine.turn
|
val turn = engine.turn
|
||||||
val canUndo = engine.canUndo
|
val canUndo = engine.canUndo
|
||||||
val canRedo = engine.canRedo
|
val canRedo = engine.canRedo
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -22,12 +21,11 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
engine.processUserInput("d8h4")
|
engine.processUserInput("d8h4")
|
||||||
|
|
||||||
|
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
||||||
|
observer.events.last shouldBe a[CheckmateEvent]
|
||||||
|
|
||||||
// Verify CheckmateEvent
|
val event = observer.events.last.asInstanceOf[CheckmateEvent]
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe a[CheckmateEvent]
|
|
||||||
|
|
||||||
val event = observer.events.head.asInstanceOf[CheckmateEvent]
|
|
||||||
event.winner shouldBe Color.Black
|
event.winner shouldBe Color.Black
|
||||||
|
|
||||||
// Board should be reset after checkmate
|
// Board should be reset after checkmate
|
||||||
@@ -50,7 +48,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
||||||
checkEvents.size shouldBe 1
|
checkEvents.size shouldBe 1
|
||||||
checkEvents.head.turn shouldBe Color.Black // Black is now in check
|
checkEvents.head.context.turn shouldBe Color.Black // Black is now in check
|
||||||
|
|
||||||
// Shortest known stalemate is 19 moves. Here is a faster one:
|
// Shortest known stalemate is 19 moves. Here is a faster one:
|
||||||
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
|
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
|
||||||
|
|||||||
-1
@@ -2,7 +2,6 @@ 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.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent}
|
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent, MoveExecutedEvent}
|
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent, MoveExecutedEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
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
|
||||||
@@ -14,8 +13,6 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
def onGameEvent(event: GameEvent): Unit = events += event
|
def onGameEvent(event: GameEvent): Unit = events += event
|
||||||
def lastEvent: GameEvent = events.last
|
def lastEvent: GameEvent = events.last
|
||||||
|
|
||||||
// ── loadPgn happy path ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("loadPgn: valid PGN returns Right and updates board/history"):
|
test("loadPgn: valid PGN returns Right and updates board/history"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val pgn =
|
val pgn =
|
||||||
@@ -25,7 +22,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
"""
|
"""
|
||||||
val result = engine.loadPgn(pgn)
|
val result = engine.loadPgn(pgn)
|
||||||
result shouldBe Right(())
|
result shouldBe Right(())
|
||||||
engine.history.moves.length shouldBe 2
|
engine.context.moves.size shouldBe 2
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
test("loadPgn: emits PgnLoadedEvent on success"):
|
test("loadPgn: emits PgnLoadedEvent on success"):
|
||||||
@@ -52,7 +49,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
cap.events.clear()
|
cap.events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
cap.events.last shouldBe a[MoveUndoneEvent]
|
cap.events.last shouldBe a[MoveUndoneEvent]
|
||||||
engine.history.moves.length shouldBe 1
|
engine.context.moves.size shouldBe 1
|
||||||
|
|
||||||
test("loadPgn: undo then redo restores position after PGN load"):
|
test("loadPgn: undo then redo restores position after PGN load"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
@@ -65,7 +62,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
engine.redo()
|
engine.redo()
|
||||||
cap.events.last shouldBe a[MoveRedoneEvent]
|
cap.events.last shouldBe a[MoveRedoneEvent]
|
||||||
engine.board shouldBe boardAfterLoad
|
engine.board shouldBe boardAfterLoad
|
||||||
engine.history.moves.length shouldBe 2
|
engine.context.moves.size shouldBe 2
|
||||||
|
|
||||||
test("loadPgn: longer game loads all moves into command history"):
|
test("loadPgn: longer game loads all moves into command history"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
@@ -75,18 +72,14 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
|
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
|
||||||
"""
|
"""
|
||||||
engine.loadPgn(pgn) shouldBe Right(())
|
engine.loadPgn(pgn) shouldBe Right(())
|
||||||
engine.history.moves.length shouldBe 6
|
engine.context.moves.size shouldBe 6
|
||||||
engine.commandHistory.length shouldBe 6
|
engine.commandHistory.length shouldBe 6
|
||||||
|
|
||||||
test("loadPgn: invalid PGN returns Left and does not change state"):
|
test("loadPgn: invalid PGN returns Left and does not change state"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val initial = engine.board
|
|
||||||
val result = engine.loadPgn("[Event \"T\"]\n\n1. Qd4\n")
|
val result = engine.loadPgn("[Event \"T\"]\n\n1. Qd4\n")
|
||||||
result.isLeft shouldBe true
|
result.isLeft shouldBe true
|
||||||
// state is reset to initial (reset happens before replay, which fails)
|
engine.context.moves shouldBe empty
|
||||||
engine.history.moves shouldBe empty
|
|
||||||
|
|
||||||
// ── undo/redo notation events ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("undo emits MoveUndoneEvent with pgnNotation"):
|
test("undo emits MoveUndoneEvent with pgnNotation"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
@@ -98,7 +91,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
cap.events.last shouldBe a[MoveUndoneEvent]
|
cap.events.last shouldBe a[MoveUndoneEvent]
|
||||||
val evt = cap.events.last.asInstanceOf[MoveUndoneEvent]
|
val evt = cap.events.last.asInstanceOf[MoveUndoneEvent]
|
||||||
evt.pgnNotation should not be empty
|
evt.pgnNotation should not be empty
|
||||||
evt.pgnNotation shouldBe "e4" // pawn to e4
|
evt.pgnNotation shouldBe "e4"
|
||||||
|
|
||||||
test("redo emits MoveRedoneEvent with pgnNotation"):
|
test("redo emits MoveRedoneEvent with pgnNotation"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
@@ -113,17 +106,10 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
evt.pgnNotation should not be empty
|
evt.pgnNotation should not be empty
|
||||||
evt.pgnNotation shouldBe "e4"
|
evt.pgnNotation shouldBe "e4"
|
||||||
|
|
||||||
test("undo emits MoveUndoneEvent with empty notation when history is empty (after checkmate reset)"):
|
test("undo emits MoveUndoneEvent with O-O notation for castling"):
|
||||||
// Simulate state where canUndo=true but currentHistory is empty (board reset on checkmate).
|
|
||||||
// We achieve this by examining the branch: provide a MoveCommand with empty history saved.
|
|
||||||
// The simplest proxy: undo a move that reset history (stalemate/checkmate). We'll
|
|
||||||
// use a contrived engine state by direct command manipulation — instead, just verify
|
|
||||||
// that after a normal move-and-undo the notation is present; the empty-history branch
|
|
||||||
// is exercised internally when gameEnd resets state. We cover it via a castling undo.
|
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val cap = new EventCapture()
|
val cap = new EventCapture()
|
||||||
engine.subscribe(cap)
|
engine.subscribe(cap)
|
||||||
// Play moves that let white castle kingside: e4 e5 Nf3 Nc6 Bc4 Bc5 O-O
|
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.processUserInput("g1f3")
|
engine.processUserInput("g1f3")
|
||||||
@@ -140,7 +126,6 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val cap = new EventCapture()
|
val cap = new EventCapture()
|
||||||
engine.subscribe(cap)
|
engine.subscribe(cap)
|
||||||
// White builds a capture on the a-file: b4, ... a6, b5, ... h6, bxa6
|
|
||||||
engine.processUserInput("b2b4")
|
engine.processUserInput("b2b4")
|
||||||
engine.processUserInput("a7a6")
|
engine.processUserInput("a7a6")
|
||||||
engine.processUserInput("b4b5")
|
engine.processUserInput("b4b5")
|
||||||
@@ -159,7 +144,6 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
val pgn = "[Event \"T\"]\n\n1. d4 d5\n"
|
val pgn = "[Event \"T\"]\n\n1. d4 d5\n"
|
||||||
engine.loadPgn(pgn) shouldBe Right(())
|
engine.loadPgn(pgn) shouldBe Right(())
|
||||||
// First move should be d4, not e4
|
engine.context.moves.head.to shouldBe de.nowchess.api.board.Square(
|
||||||
engine.history.moves.head.to shouldBe de.nowchess.api.board.Square(
|
|
||||||
de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4
|
de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-36
@@ -1,8 +1,8 @@
|
|||||||
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.move.PromotionPiece
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.move.{MoveType, PromotionPiece}
|
||||||
import de.nowchess.chess.notation.FenParser
|
import de.nowchess.chess.notation.FenParser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
@@ -17,9 +17,12 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
||||||
events
|
events
|
||||||
|
|
||||||
|
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 fires PromotionRequiredEvent 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 = new GameEngine(initialBoard = promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
@@ -30,7 +33,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("isPendingPromotion is true after PromotionRequired input") {
|
test("isPendingPromotion is true after PromotionRequired input") {
|
||||||
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 = new GameEngine(initialBoard = promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
captureEvents(engine)
|
captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
@@ -45,7 +48,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
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 = new GameEngine(initialBoard = promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
@@ -54,13 +57,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.isPendingPromotion should be (false)
|
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.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with rook underpromotion") {
|
test("completePromotion with rook underpromotion") {
|
||||||
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 = new GameEngine(initialBoard = promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
captureEvents(engine)
|
captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
@@ -81,7 +84,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
test("completePromotion 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 = new GameEngine(initialBoard = promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
@@ -91,9 +94,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||||
// White pawn on e7, black king on a2 (far away, not in check after promotion)
|
|
||||||
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 = new GameEngine(initialBoard = board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
@@ -106,10 +108,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||||
// Black king on a8, white king on b6, white pawn on h7
|
|
||||||
// h7->h8=Q 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 = new GameEngine(initialBoard = board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("h7h8")
|
engine.processUserInput("h7h8")
|
||||||
@@ -120,10 +120,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||||
// Black king on a8, white pawn on b7, white bishop on c7, white king on b6
|
|
||||||
// b7->b8=N: no check; Ka8 has no legal moves -> 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 = new GameEngine(initialBoard = board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("b7b8")
|
engine.processUserInput("b7b8")
|
||||||
@@ -134,10 +132,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with black pawn promotion results in Moved") {
|
test("completePromotion with black pawn promotion results in Moved") {
|
||||||
// Black pawn e2, white king h3 (not on rank 1 or file e), black king a8
|
|
||||||
// e2->e1=Q: queen on e1 does not attack h3 -> normal Moved
|
|
||||||
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 = new GameEngine(initialBoard = board, initialTurn = Color.Black)
|
val engine = engineWith(board, Color.Black)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e2e1")
|
engine.processUserInput("e2e1")
|
||||||
@@ -148,20 +144,3 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion catch-all fires InvalidMoveEvent for unexpected MoveResult") {
|
|
||||||
// Inject a function that returns an unexpected MoveResult to hit the catch-all case
|
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
val stubFn: (de.nowchess.api.board.Board, de.nowchess.chess.logic.GameHistory, Square, Square, PromotionPiece, Color) => de.nowchess.chess.controller.MoveResult =
|
|
||||||
(_, _, _, _, _, _) => de.nowchess.chess.controller.MoveResult.NoPiece
|
|
||||||
val engine = new GameEngine(initialBoard = promotionBoard, completePromotionFn = stubFn)
|
|
||||||
val events = captureEvents(engine)
|
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
|
||||||
engine.isPendingPromotion should be (true)
|
|
||||||
|
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
|
||||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent, MoveUndoneEvent, MoveRedoneEvent}
|
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent, MoveUndoneEvent, MoveRedoneEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -12,7 +12,7 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
test("GameEngine starts with initial board state"):
|
test("GameEngine starts with initial board state"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
engine.history shouldBe GameHistory.empty
|
engine.context.moves shouldBe empty
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
test("GameEngine accepts Observer subscription"):
|
test("GameEngine accepts Observer subscription"):
|
||||||
@@ -112,7 +112,6 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
test("GameEngine undo restores previous state"):
|
test("GameEngine undo restores previous state"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
val boardAfterMove = engine.board
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
@@ -175,7 +174,6 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
test("GameEngine undo via processUserInput"):
|
test("GameEngine undo via processUserInput"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
val boardAfterMove = engine.board
|
|
||||||
engine.processUserInput("undo")
|
engine.processUserInput("undo")
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
|
|
||||||
@@ -200,15 +198,11 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.processUserInput("g1f3")
|
engine.processUserInput("g1f3")
|
||||||
|
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
@@ -218,17 +212,13 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.processUserInput("g1f3")
|
engine.processUserInput("g1f3")
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
|
|
||||||
engine.redo()
|
engine.redo()
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
engine.redo()
|
engine.redo()
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
engine.redo()
|
engine.redo()
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
@@ -238,17 +228,14 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.undo()
|
engine.undo()
|
||||||
engine.canRedo shouldBe true
|
engine.canRedo shouldBe true
|
||||||
|
engine.processUserInput("e7e6")
|
||||||
engine.processUserInput("e7e6") // Different move
|
|
||||||
engine.canRedo shouldBe false
|
engine.canRedo shouldBe false
|
||||||
|
|
||||||
test("GameEngine command history tracking"):
|
test("GameEngine command history tracking"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.commandHistory.size shouldBe 0
|
engine.commandHistory.size shouldBe 0
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.commandHistory.size shouldBe 1
|
engine.commandHistory.size shouldBe 1
|
||||||
|
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.commandHistory.size shouldBe 2
|
engine.commandHistory.size shouldBe 2
|
||||||
|
|
||||||
@@ -258,7 +245,6 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
val initialEvents = observer.events.size
|
val initialEvents = observer.events.size
|
||||||
engine.processUserInput("quit")
|
engine.processUserInput("quit")
|
||||||
// quit should not produce an event
|
|
||||||
observer.events.size shouldBe initialEvents
|
observer.events.size shouldBe initialEvents
|
||||||
|
|
||||||
test("GameEngine quit via q"):
|
test("GameEngine quit via q"):
|
||||||
@@ -268,7 +254,7 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
val initialEvents = observer.events.size
|
val initialEvents = observer.events.size
|
||||||
engine.processUserInput("q")
|
engine.processUserInput("q")
|
||||||
observer.events.size shouldBe initialEvents
|
observer.events.size shouldBe initialEvents
|
||||||
|
|
||||||
test("GameEngine undo notifies with MoveUndoneEvent after successful undo"):
|
test("GameEngine undo notifies with MoveUndoneEvent after successful undo"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
@@ -276,10 +262,7 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
val observer = new MockObserver()
|
val observer = new MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
|
|
||||||
// Should have received a MoveUndoneEvent on undo
|
|
||||||
observer.events.size should be > 0
|
observer.events.size should be > 0
|
||||||
observer.events.exists(_.isInstanceOf[MoveUndoneEvent]) shouldBe true
|
observer.events.exists(_.isInstanceOf[MoveUndoneEvent]) shouldBe true
|
||||||
|
|
||||||
@@ -288,22 +271,16 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
val boardAfterSecondMove = engine.board
|
val boardAfterSecondMove = engine.board
|
||||||
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
val observer = new MockObserver()
|
val observer = new MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
|
|
||||||
engine.redo()
|
engine.redo()
|
||||||
|
|
||||||
// Should have received a MoveRedoneEvent for the redo
|
|
||||||
observer.events.size shouldBe 1
|
observer.events.size shouldBe 1
|
||||||
observer.events.head shouldBe a[MoveRedoneEvent]
|
observer.events.head shouldBe a[MoveRedoneEvent]
|
||||||
engine.board shouldBe boardAfterSecondMove
|
engine.board shouldBe boardAfterSecondMove
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
// ──── 50-move rule ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
|
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val observer = new MockObserver()
|
val observer = new MockObserver()
|
||||||
@@ -313,7 +290,7 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||||
|
|
||||||
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
|
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
|
||||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(100))
|
||||||
val observer = new MockObserver()
|
val observer = new MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
engine.processUserInput("draw")
|
engine.processUserInput("draw")
|
||||||
@@ -321,31 +298,27 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
observer.events.head shouldBe a[DrawClaimedEvent]
|
observer.events.head shouldBe a[DrawClaimedEvent]
|
||||||
|
|
||||||
test("GameEngine: state resets to initial after draw claimed"):
|
test("GameEngine: state resets to initial after draw claimed"):
|
||||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(100))
|
||||||
engine.processUserInput("draw")
|
engine.processUserInput("draw")
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
engine.history shouldBe GameHistory.empty
|
engine.context.moves shouldBe empty
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
|
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
|
||||||
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
|
val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(99))
|
||||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
|
|
||||||
val observer = new MockObserver()
|
val observer = new MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
engine.processUserInput("g1f3") // knight move on initial board
|
engine.processUserInput("g1f3")
|
||||||
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
|
|
||||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
|
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
|
||||||
|
|
||||||
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
|
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
|
||||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
|
val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(5))
|
||||||
val observer = new MockObserver()
|
val observer = new MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
engine.processUserInput("g1f3")
|
engine.processUserInput("g1f3")
|
||||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
|
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
|
||||||
|
|
||||||
// Mock Observer for testing
|
|
||||||
private class MockObserver extends Observer:
|
private class MockObserver extends Observer:
|
||||||
val events = mutable.ListBuffer[GameEvent]()
|
val events = mutable.ListBuffer[GameEvent]()
|
||||||
override def onGameEvent(event: GameEvent): Unit =
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
events += event
|
events += event
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class MoveCommandDefaultsTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
// Tests for MoveCommand with default parameter values
|
|
||||||
test("MoveCommand with no moveResult defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.moveResult shouldBe None
|
|
||||||
cmd.execute() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand with no previousBoard defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.previousBoard shouldBe None
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand with no previousHistory defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.previousHistory shouldBe None
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand with no previousTurn defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.previousTurn shouldBe None
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand description is always returned"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.description shouldBe "Move from e2 to e4"
|
|
||||||
|
|
||||||
test("MoveCommand execute returns false when moveResult is None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.A, Rank.R1),
|
|
||||||
to = sq(File.B, Rank.R3)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand undo returns false when any previous state is None"):
|
|
||||||
// Missing previousBoard
|
|
||||||
val cmd1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = None,
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd1.undo() shouldBe false
|
|
||||||
|
|
||||||
// Missing previousHistory
|
|
||||||
val cmd2 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd2.undo() shouldBe false
|
|
||||||
|
|
||||||
// Missing previousTurn
|
|
||||||
val cmd3 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = None
|
|
||||||
)
|
|
||||||
cmd3.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand execute returns true when moveResult is defined"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
|
|
||||||
test("MoveCommand undo returns true when all previous states are defined"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.undo() shouldBe true
|
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class CastlingRightsCalculatorTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
test("Empty history gives full castling rights"):
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(GameHistory.empty, Color.White)
|
|
||||||
rights shouldBe CastlingRights.Both
|
|
||||||
|
|
||||||
test("White loses kingside rights after h1 rook moves"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.H, Rank.R1), sq(File.H, Rank.R2))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
|
||||||
rights.kingSide shouldBe false
|
|
||||||
rights.queenSide shouldBe true
|
|
||||||
|
|
||||||
test("White loses queenside rights after a1 rook moves"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
|
||||||
rights.queenSide shouldBe false
|
|
||||||
rights.kingSide shouldBe true
|
|
||||||
|
|
||||||
test("White loses all rights after king moves"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
|
||||||
rights shouldBe CastlingRights.None
|
|
||||||
|
|
||||||
test("Black loses kingside rights after h8 rook moves"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.H, Rank.R8), sq(File.H, Rank.R7))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
|
||||||
rights.kingSide shouldBe false
|
|
||||||
rights.queenSide shouldBe true
|
|
||||||
|
|
||||||
test("Black loses queenside rights after a8 rook moves"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.A, Rank.R8), sq(File.A, Rank.R7))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
|
||||||
rights.queenSide shouldBe false
|
|
||||||
rights.kingSide shouldBe true
|
|
||||||
|
|
||||||
test("Black loses all rights after king moves"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R8), sq(File.E, Rank.R7))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
|
||||||
rights shouldBe CastlingRights.None
|
|
||||||
|
|
||||||
test("Castle move revokes all castling rights"):
|
|
||||||
val history = GameHistory.empty.addMove(
|
|
||||||
sq(File.E, Rank.R1),
|
|
||||||
sq(File.G, Rank.R1),
|
|
||||||
Some(CastleSide.Kingside)
|
|
||||||
)
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
|
||||||
rights shouldBe CastlingRights.None
|
|
||||||
|
|
||||||
test("Other pieces moving does not revoke castling rights"):
|
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
|
||||||
rights shouldBe CastlingRights.Both
|
|
||||||
|
|
||||||
test("Multiple moves preserve white kingside but lose queenside"):
|
|
||||||
val history = GameHistory.empty
|
|
||||||
.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2)) // White queenside rook moves
|
|
||||||
.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) // Black pawn moves
|
|
||||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
|
||||||
rights.kingSide shouldBe true
|
|
||||||
rights.queenSide shouldBe false
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class EnPassantCalculatorTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
|
||||||
|
|
||||||
// ──── enPassantTarget ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("enPassantTarget returns None for empty history"):
|
|
||||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
|
||||||
EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None
|
|
||||||
|
|
||||||
test("enPassantTarget returns None when last move was a single pawn push"):
|
|
||||||
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3))
|
|
||||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
|
||||||
|
|
||||||
test("enPassantTarget returns None when last move was not a pawn"):
|
|
||||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
|
||||||
|
|
||||||
test("enPassantTarget returns e3 after white pawn double push e2-e4"):
|
|
||||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3))
|
|
||||||
|
|
||||||
test("enPassantTarget returns e6 after black pawn double push e7-e5"):
|
|
||||||
val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6))
|
|
||||||
|
|
||||||
test("enPassantTarget returns d3 after white pawn double push d2-d4"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3))
|
|
||||||
|
|
||||||
// ──── capturedPawnSquare ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("capturedPawnSquare for white capturing on e6 returns e5"):
|
|
||||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5)
|
|
||||||
|
|
||||||
test("capturedPawnSquare for black capturing on e3 returns e4"):
|
|
||||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4)
|
|
||||||
|
|
||||||
test("capturedPawnSquare for white capturing on d6 returns d5"):
|
|
||||||
EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5)
|
|
||||||
|
|
||||||
// ──── isEnPassant ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("isEnPassant returns true for valid white en passant capture"):
|
|
||||||
// White pawn on e5, black pawn just double-pushed to d5 (ep target = d6)
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
|
||||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true
|
|
||||||
|
|
||||||
test("isEnPassant returns true for valid black en passant capture"):
|
|
||||||
// Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3)
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.BlackPawn,
|
|
||||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true
|
|
||||||
|
|
||||||
test("isEnPassant returns false when no en passant target in history"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
|
|
||||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
|
||||||
|
|
||||||
test("isEnPassant returns false when piece at from is not a pawn"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhiteRook,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
|
||||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
|
||||||
|
|
||||||
test("isEnPassant returns false when to does not match ep target"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
|
||||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false
|
|
||||||
|
|
||||||
test("isEnPassant returns false when from square is empty"):
|
|
||||||
val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
|
||||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
|
||||||
@@ -2,6 +2,7 @@ package de.nowchess.chess.logic
|
|||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
import de.nowchess.api.game.{GameHistory, HistoryMove}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -32,9 +33,9 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
|
|||||||
val history = GameHistory.empty.addMove(
|
val history = GameHistory.empty.addMove(
|
||||||
sq(File.E, Rank.R1),
|
sq(File.E, Rank.R1),
|
||||||
sq(File.G, Rank.R1),
|
sq(File.G, Rank.R1),
|
||||||
Some(CastleSide.Kingside)
|
Some("Kingside")
|
||||||
)
|
)
|
||||||
history.moves.head.castleSide shouldBe Some(CastleSide.Kingside)
|
history.moves.head.castleSide shouldBe Some("Kingside")
|
||||||
|
|
||||||
test("GameHistory.addMove with two arguments uses None for castleSide default"):
|
test("GameHistory.addMove with two arguments uses None for castleSide default"):
|
||||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
@@ -58,9 +59,9 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
|
|||||||
test("addMove with castleSide only uses promotionPiece default (None)"):
|
test("addMove with castleSide only uses promotionPiece default (None)"):
|
||||||
val history = GameHistory.empty
|
val history = GameHistory.empty
|
||||||
// With overload 3 removed, this uses the 4-param version and triggers addMove$default$4
|
// With overload 3 removed, this uses the 4-param version and triggers addMove$default$4
|
||||||
val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some(CastleSide.Kingside))
|
val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some("Kingside"))
|
||||||
newHistory.moves should have length 1
|
newHistory.moves should have length 1
|
||||||
newHistory.moves.head.castleSide should be (Some(CastleSide.Kingside))
|
newHistory.moves.head.castleSide should be (Some("Kingside"))
|
||||||
newHistory.moves.head.promotionPiece should be (None)
|
newHistory.moves.head.promotionPiece should be (None)
|
||||||
|
|
||||||
test("addMove using named parameters with only promotion, using castleSide default"):
|
test("addMove using named parameters with only promotion, using castleSide default"):
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class GameRulesTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
|
||||||
|
|
||||||
/** Wrap a board in a GameContext with no castling rights — for non-castling tests. */
|
|
||||||
private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] =
|
|
||||||
GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color)
|
|
||||||
|
|
||||||
private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus =
|
|
||||||
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color)
|
|
||||||
|
|
||||||
// ──── isInCheck ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("isInCheck: king attacked by enemy rook on same rank"):
|
|
||||||
// White King E1, Black Rook A1 — rook slides along rank 1 to E1
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.BlackRook
|
|
||||||
)
|
|
||||||
GameRules.isInCheck(b, Color.White) shouldBe true
|
|
||||||
|
|
||||||
test("isInCheck: king not attacked"):
|
|
||||||
// Black Rook A3 does not cover E1
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R3) -> Piece.BlackRook
|
|
||||||
)
|
|
||||||
GameRules.isInCheck(b, Color.White) shouldBe false
|
|
||||||
|
|
||||||
test("isInCheck: no king on board returns false"):
|
|
||||||
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
|
|
||||||
GameRules.isInCheck(b, Color.White) shouldBe false
|
|
||||||
|
|
||||||
// ──── legalMoves ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("legalMoves: move that exposes own king to rook is excluded"):
|
|
||||||
// White King E1, White Rook E4 (pinned on E-file), Black Rook E8
|
|
||||||
// Moving the White Rook off the E-file would expose the king
|
|
||||||
val moves = testLegalMoves(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.E, Rank.R4) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
|
||||||
)(Color.White)
|
|
||||||
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
|
|
||||||
|
|
||||||
test("legalMoves: move that blocks check is included"):
|
|
||||||
// White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5
|
|
||||||
val moves = testLegalMoves(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
|
||||||
)(Color.White)
|
|
||||||
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
|
|
||||||
|
|
||||||
// ──── gameStatus ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("gameStatus: checkmate returns Mated"):
|
|
||||||
// White Qh8, Ka6; Black Ka8
|
|
||||||
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
|
|
||||||
testGameStatus(
|
|
||||||
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
|
|
||||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
)(Color.Black) shouldBe PositionStatus.Mated
|
|
||||||
|
|
||||||
test("gameStatus: stalemate returns Drawn"):
|
|
||||||
// White Qb6, Kc6; Black Ka8
|
|
||||||
// Black king has no legal moves and is not in check (spec-verified position)
|
|
||||||
testGameStatus(
|
|
||||||
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
|
|
||||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
)(Color.Black) shouldBe PositionStatus.Drawn
|
|
||||||
|
|
||||||
test("gameStatus: king in check with legal escape returns InCheck"):
|
|
||||||
// White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7
|
|
||||||
testGameStatus(
|
|
||||||
sq(File.A, Rank.R8) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
|
||||||
)(Color.Black) shouldBe PositionStatus.InCheck
|
|
||||||
|
|
||||||
test("gameStatus: normal starting position returns Normal"):
|
|
||||||
GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
|
|
||||||
|
|
||||||
test("legalMoves: includes castling destination when available"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
)
|
|
||||||
GameRules.legalMoves(b, GameHistory.empty, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
|
|
||||||
|
|
||||||
test("legalMoves: excludes castling when king is in check"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
)
|
|
||||||
GameRules.legalMoves(b, GameHistory.empty, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
|
|
||||||
|
|
||||||
test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"):
|
|
||||||
// White King e1, Rook h1 (kingside castling available).
|
|
||||||
// Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both,
|
|
||||||
// f1 attacked by f2. King cannot move to any adjacent square without entering
|
|
||||||
// an attacked square or an enemy piece. Only legal move: castle to g1.
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.D, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.F, Rank.R2) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
|
||||||
)
|
|
||||||
// No history means castling rights are intact
|
|
||||||
GameRules.gameStatus(b, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
|
|
||||||
|
|
||||||
test("CastleSide.withCastle correctly positions pieces for Queenside castling"):
|
|
||||||
// Directly test the withCastle extension for Queenside (coverage gap on line 10)
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
|
||||||
)
|
|
||||||
val result = b.withCastle(Color.White, CastleSide.Queenside)
|
|
||||||
result.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
|
||||||
result.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
|
||||||
result.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
|
||||||
result.pieceAt(sq(File.A, Rank.R1)) shouldBe None
|
|
||||||
|
|
||||||
test("CastleSide.withCastle correctly positions pieces for Black Kingside castling"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
|
||||||
)
|
|
||||||
val result = b.withCastle(Color.Black, CastleSide.Kingside)
|
|
||||||
result.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
|
||||||
result.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
|
||||||
result.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
|
||||||
result.pieceAt(sq(File.H, Rank.R8)) shouldBe None
|
|
||||||
|
|
||||||
test("CastleSide.withCastle correctly positions pieces for Black Queenside castling"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
|
||||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
|
||||||
)
|
|
||||||
val result = b.withCastle(Color.Black, CastleSide.Queenside)
|
|
||||||
result.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
|
||||||
result.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
|
||||||
result.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
|
||||||
result.pieceAt(sq(File.A, Rank.R8)) shouldBe None
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
package de.nowchess.chess.logic
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
|
||||||
import de.nowchess.api.game.CastlingRights
|
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
|
||||||
import de.nowchess.chess.notation.FenParser
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class MoveValidatorTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
|
||||||
|
|
||||||
// ──── Empty square ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("legalTargets returns empty set when no piece at from square"):
|
|
||||||
MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R4)) shouldBe empty
|
|
||||||
|
|
||||||
// ──── isLegal delegates to legalTargets ──────────────────────────────
|
|
||||||
|
|
||||||
test("isLegal returns true for a valid pawn move"):
|
|
||||||
MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true
|
|
||||||
|
|
||||||
test("isLegal returns false for an invalid move"):
|
|
||||||
MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false
|
|
||||||
|
|
||||||
// ──── Pawn – White ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("white pawn on starting rank can move forward one square"):
|
|
||||||
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3))
|
|
||||||
|
|
||||||
test("white pawn on starting rank can move forward two squares"):
|
|
||||||
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4))
|
|
||||||
|
|
||||||
test("white pawn not on starting rank cannot move two squares"):
|
|
||||||
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5)
|
|
||||||
|
|
||||||
test("white pawn is blocked by piece directly in front, and cannot jump over it"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
|
||||||
sq(File.E, Rank.R3) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
|
|
||||||
targets should not contain sq(File.E, Rank.R3)
|
|
||||||
targets should not contain sq(File.E, Rank.R4)
|
|
||||||
|
|
||||||
test("white pawn on starting rank cannot move two squares if destination square is occupied"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
|
||||||
sq(File.E, Rank.R4) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
|
|
||||||
targets should contain(sq(File.E, Rank.R3))
|
|
||||||
targets should not contain sq(File.E, Rank.R4)
|
|
||||||
|
|
||||||
test("white pawn can capture diagonally when enemy piece is present"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R3) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3))
|
|
||||||
|
|
||||||
test("white pawn cannot capture diagonally when no enemy piece is present"):
|
|
||||||
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3)
|
|
||||||
|
|
||||||
test("white pawn at A-file does not generate diagonal to the left off the board"):
|
|
||||||
val b = board(sq(File.A, Rank.R2) -> Piece.WhitePawn)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R2))
|
|
||||||
targets should contain(sq(File.A, Rank.R3))
|
|
||||||
targets should contain(sq(File.A, Rank.R4))
|
|
||||||
targets.size shouldBe 2
|
|
||||||
|
|
||||||
// ──── Pawn – Black ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("black pawn on starting rank can move forward one and two squares"):
|
|
||||||
val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R7))
|
|
||||||
targets should contain(sq(File.E, Rank.R6))
|
|
||||||
targets should contain(sq(File.E, Rank.R5))
|
|
||||||
|
|
||||||
test("black pawn not on starting rank cannot move two squares"):
|
|
||||||
val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4)
|
|
||||||
|
|
||||||
test("black pawn can capture diagonally when enemy piece is present"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R7) -> Piece.BlackPawn,
|
|
||||||
sq(File.F, Rank.R6) -> Piece.WhitePawn
|
|
||||||
)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6))
|
|
||||||
|
|
||||||
// ──── Knight ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("knight in center has 8 possible moves"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
|
|
||||||
|
|
||||||
test("knight in corner has only 2 possible moves"):
|
|
||||||
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 2
|
|
||||||
|
|
||||||
test("knight cannot land on own piece"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
|
|
||||||
sq(File.F, Rank.R5) -> Piece.WhiteRook
|
|
||||||
)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5)
|
|
||||||
|
|
||||||
test("knight can capture enemy piece"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
|
|
||||||
sq(File.F, Rank.R5) -> Piece.BlackRook
|
|
||||||
)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5))
|
|
||||||
|
|
||||||
// ──── Bishop ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("bishop slides diagonally across an empty board"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
|
||||||
targets should contain(sq(File.E, Rank.R5))
|
|
||||||
targets should contain(sq(File.H, Rank.R8))
|
|
||||||
targets should contain(sq(File.C, Rank.R3))
|
|
||||||
targets should contain(sq(File.A, Rank.R1))
|
|
||||||
|
|
||||||
test("bishop is blocked by own piece and squares beyond are unreachable"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
|
|
||||||
sq(File.F, Rank.R6) -> Piece.WhiteRook
|
|
||||||
)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
|
||||||
targets should contain(sq(File.E, Rank.R5))
|
|
||||||
targets should not contain sq(File.F, Rank.R6)
|
|
||||||
targets should not contain sq(File.G, Rank.R7)
|
|
||||||
|
|
||||||
test("bishop captures enemy piece and cannot slide further"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
|
|
||||||
sq(File.F, Rank.R6) -> Piece.BlackRook
|
|
||||||
)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
|
||||||
targets should contain(sq(File.E, Rank.R5))
|
|
||||||
targets should contain(sq(File.F, Rank.R6))
|
|
||||||
targets should not contain sq(File.G, Rank.R7)
|
|
||||||
|
|
||||||
// ──── Rook ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("rook slides orthogonally across an empty board"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
|
||||||
targets should contain(sq(File.D, Rank.R8))
|
|
||||||
targets should contain(sq(File.D, Rank.R1))
|
|
||||||
targets should contain(sq(File.A, Rank.R4))
|
|
||||||
targets should contain(sq(File.H, Rank.R4))
|
|
||||||
|
|
||||||
test("rook is blocked by own piece and squares beyond are unreachable"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.C, Rank.R1) -> Piece.WhitePawn
|
|
||||||
)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
|
|
||||||
targets should contain(sq(File.B, Rank.R1))
|
|
||||||
targets should not contain sq(File.C, Rank.R1)
|
|
||||||
targets should not contain sq(File.D, Rank.R1)
|
|
||||||
|
|
||||||
test("rook captures enemy piece and cannot slide further"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
|
||||||
sq(File.C, Rank.R1) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
|
|
||||||
targets should contain(sq(File.B, Rank.R1))
|
|
||||||
targets should contain(sq(File.C, Rank.R1))
|
|
||||||
targets should not contain sq(File.D, Rank.R1)
|
|
||||||
|
|
||||||
// ──── Queen ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("queen combines rook and bishop movement for 27 squares from d4"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen)
|
|
||||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
|
||||||
targets should contain(sq(File.D, Rank.R8))
|
|
||||||
targets should contain(sq(File.H, Rank.R4))
|
|
||||||
targets should contain(sq(File.H, Rank.R8))
|
|
||||||
targets should contain(sq(File.A, Rank.R1))
|
|
||||||
targets.size shouldBe 27
|
|
||||||
|
|
||||||
// ──── King ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("king moves one step in all 8 directions from center"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
|
|
||||||
|
|
||||||
test("king at corner has only 3 reachable squares"):
|
|
||||||
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 3
|
|
||||||
|
|
||||||
test("king cannot capture own piece"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.WhiteKing,
|
|
||||||
sq(File.E, Rank.R4) -> Piece.WhiteRook
|
|
||||||
)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4)
|
|
||||||
|
|
||||||
test("king can capture enemy piece"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.WhiteKing,
|
|
||||||
sq(File.E, Rank.R4) -> Piece.BlackRook
|
|
||||||
)
|
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
|
|
||||||
|
|
||||||
// ──── Pawn – en passant targets ──────────────────────────────────────
|
|
||||||
|
|
||||||
test("white pawn includes ep target in legal moves after black double push"):
|
|
||||||
// Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
|
||||||
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
|
|
||||||
|
|
||||||
test("white pawn does not include ep target without a preceding double push"):
|
|
||||||
val b = board(
|
|
||||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
|
|
||||||
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
|
|
||||||
|
|
||||||
test("black pawn includes ep target in legal moves after white double push"):
|
|
||||||
// White pawn just double-pushed to e4 (ep target = e3); black pawn on d4
|
|
||||||
val b = board(
|
|
||||||
sq(File.D, Rank.R4) -> Piece.BlackPawn,
|
|
||||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
|
|
||||||
|
|
||||||
test("pawn on wrong file does not get ep target from adjacent double push"):
|
|
||||||
// White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5
|
|
||||||
val b = board(
|
|
||||||
sq(File.A, Rank.R5) -> Piece.WhitePawn,
|
|
||||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
|
||||||
)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
|
||||||
MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
|
|
||||||
|
|
||||||
// ──── History-aware legalTargets fallback for non-pawn non-king pieces ─────
|
|
||||||
|
|
||||||
test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"):
|
|
||||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
|
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
|
||||||
|
|
||||||
// ──── isPromotionMove ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("White pawn reaching R8 is a promotion move"):
|
|
||||||
val b = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
|
||||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true)
|
|
||||||
|
|
||||||
test("Black pawn reaching R1 is a promotion move"):
|
|
||||||
val b = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
|
||||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true)
|
|
||||||
|
|
||||||
test("Pawn capturing to back rank is a promotion move"):
|
|
||||||
val b = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
|
||||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true)
|
|
||||||
|
|
||||||
test("Pawn not reaching back rank is not a promotion move"):
|
|
||||||
val b = FenParser.parseBoard("8/8/8/4P3/8/8/8/8").get
|
|
||||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false)
|
|
||||||
|
|
||||||
test("Non-pawn piece is never a promotion move"):
|
|
||||||
val b = FenParser.parseBoard("8/8/8/4Q3/8/8/8/8").get
|
|
||||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false)
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.game.*
|
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||||
|
|
||||||
test("halfMoveClock round-trips through FEN export and import"):
|
test("halfMoveClock round-trips through FEN export and import"):
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameHistory
|
||||||
import de.nowchess.chess.notation.FenParser
|
import de.nowchess.chess.notation.FenParser
|
||||||
val history = GameHistory(halfMoveClock = 42)
|
val history = GameHistory(halfMoveClock = 42)
|
||||||
val gameState = GameState(
|
val gameState = GameState(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.nowchess.chess.notation
|
|||||||
|
|
||||||
import de.nowchess.api.board.{PieceType, *}
|
import de.nowchess.api.board.{PieceType, *}
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
import de.nowchess.api.game.{GameHistory, HistoryMove}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
test("export castling") {
|
test("export castling") {
|
||||||
val headers = Map("Event" -> "Test")
|
val headers = Map("Event" -> "Test")
|
||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some(CastleSide.Kingside)))
|
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some("Kingside")))
|
||||||
val pgn = PgnExporter.exportGame(headers, history)
|
val pgn = PgnExporter.exportGame(headers, history)
|
||||||
|
|
||||||
pgn.contains("O-O") shouldBe true
|
pgn.contains("O-O") shouldBe true
|
||||||
@@ -59,7 +59,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
test("export queenside castling") {
|
test("export queenside castling") {
|
||||||
val headers = Map("Event" -> "Test")
|
val headers = Map("Event" -> "Test")
|
||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some(CastleSide.Queenside)))
|
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some("Queenside")))
|
||||||
val pgn = PgnExporter.exportGame(headers, history)
|
val pgn = PgnExporter.exportGame(headers, history)
|
||||||
|
|
||||||
pgn.contains("O-O-O") shouldBe true
|
pgn.contains("O-O-O") shouldBe true
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.{MoveType, PromotionPiece}
|
||||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.notation.FenParser
|
import de.nowchess.chess.notation.FenParser
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -49,13 +49,13 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
[White "A"]
|
[White "A"]
|
||||||
[Black "B"]
|
[Black "B"]
|
||||||
|
|
||||||
1. e4 e5 2. Nxe5
|
1. Nf3 e5 2. Nxe5
|
||||||
"""
|
"""
|
||||||
val game = PgnParser.parsePgn(pgn)
|
val game = PgnParser.parsePgn(pgn)
|
||||||
|
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
game.get.moves.length shouldBe 3
|
game.get.moves.length shouldBe 3
|
||||||
// Nxe5: knight captures on e5
|
// Nxe5: knight on f3 captures pawn on e5
|
||||||
game.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
game.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,16 +89,16 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse PGN black kingside castling O-O") {
|
test("parse PGN black kingside castling O-O") {
|
||||||
// After e4 e5 Nf3 Nc6 Bc4 Bc5, black can castle kingside
|
// After e4 e5 Nf3 Nf6 Bc4 Be7, both sides have cleared kingside for castling
|
||||||
val pgn = """[Event "Test"]
|
val pgn = """[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O
|
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O
|
||||||
"""
|
"""
|
||||||
val game = PgnParser.parsePgn(pgn)
|
val game = PgnParser.parsePgn(pgn)
|
||||||
|
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
val blackCastle = game.get.moves.last
|
val blackCastle = game.get.moves.last
|
||||||
blackCastle.castleSide shouldBe Some(CastleSide.Kingside)
|
blackCastle.castleSide shouldBe Some("Kingside")
|
||||||
blackCastle.from shouldBe Square(File.E, Rank.R8)
|
blackCastle.from shouldBe Square(File.E, Rank.R8)
|
||||||
blackCastle.to shouldBe Square(File.G, Rank.R8)
|
blackCastle.to shouldBe Square(File.G, Rank.R8)
|
||||||
}
|
}
|
||||||
@@ -117,28 +117,25 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parseAlgebraicMove: unrecognised token returns None and is skipped") {
|
test("parseAlgebraicMove: unrecognised token returns None and is skipped") {
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
val history = GameHistory.empty
|
|
||||||
// "zzz" is not valid algebraic notation
|
// "zzz" is not valid algebraic notation
|
||||||
val result = PgnParser.parseAlgebraicMove("zzz", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("zzz", GameContext.initial.withBoard(board), Color.White)
|
||||||
result shouldBe None
|
result shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseAlgebraicMove: piece moves use charToPieceType for N B R Q K") {
|
test("parseAlgebraicMove: piece moves use charToPieceType for N B R Q K") {
|
||||||
// Test that piece type characters are recognised
|
// Test that piece type characters are recognised
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
val history = GameHistory.empty
|
|
||||||
|
|
||||||
// Nf3 - knight move
|
// Nf3 - knight move
|
||||||
val nMove = PgnParser.parseAlgebraicMove("Nf3", board, history, Color.White)
|
val nMove = PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White)
|
||||||
nMove.isDefined shouldBe true
|
nMove.isDefined shouldBe true
|
||||||
nMove.get.to shouldBe Square(File.F, Rank.R3)
|
nMove.get.to shouldBe Square(File.F, Rank.R3)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseAlgebraicMove: single char that is too short returns None") {
|
test("parseAlgebraicMove: single char that is too short returns None") {
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
val history = GameHistory.empty
|
|
||||||
// Single char that is not castling and cleaned length < 2
|
// Single char that is not castling and cleaned length < 2
|
||||||
val result = PgnParser.parseAlgebraicMove("e", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("e", GameContext.initial.withBoard(board), Color.White)
|
||||||
result shouldBe None
|
result shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +150,8 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val board = Board(pieces)
|
val board = Board(pieces)
|
||||||
val history = GameHistory.empty
|
|
||||||
|
|
||||||
val result = PgnParser.parseAlgebraicMove("Rad1", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.isDefined shouldBe true
|
result.isDefined shouldBe true
|
||||||
result.get.from shouldBe Square(File.A, Rank.R1)
|
result.get.from shouldBe Square(File.A, Rank.R1)
|
||||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
result.get.to shouldBe Square(File.D, Rank.R1)
|
||||||
@@ -171,9 +167,8 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val board = Board(pieces)
|
val board = Board(pieces)
|
||||||
val history = GameHistory.empty
|
|
||||||
|
|
||||||
val result = PgnParser.parseAlgebraicMove("R1a3", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.isDefined shouldBe true
|
result.isDefined shouldBe true
|
||||||
result.get.from shouldBe Square(File.A, Rank.R1)
|
result.get.from shouldBe Square(File.A, Rank.R1)
|
||||||
result.get.to shouldBe Square(File.A, Rank.R3)
|
result.get.to shouldBe Square(File.A, Rank.R3)
|
||||||
@@ -188,7 +183,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val boardBishop = Board(piecesForBishop)
|
val boardBishop = Board(piecesForBishop)
|
||||||
val bResult = PgnParser.parseAlgebraicMove("Bd2", boardBishop, GameHistory.empty, Color.White)
|
val bResult = PgnParser.parseAlgebraicMove("Bd2", GameContext.initial.withBoard(boardBishop), Color.White)
|
||||||
bResult.isDefined shouldBe true
|
bResult.isDefined shouldBe true
|
||||||
|
|
||||||
// Rook move
|
// Rook move
|
||||||
@@ -198,7 +193,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val boardRook = Board(piecesForRook)
|
val boardRook = Board(piecesForRook)
|
||||||
val rResult = PgnParser.parseAlgebraicMove("Ra4", boardRook, GameHistory.empty, Color.White)
|
val rResult = PgnParser.parseAlgebraicMove("Ra4", GameContext.initial.withBoard(boardRook), Color.White)
|
||||||
rResult.isDefined shouldBe true
|
rResult.isDefined shouldBe true
|
||||||
|
|
||||||
// Queen move
|
// Queen move
|
||||||
@@ -208,7 +203,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val boardQueen = Board(piecesForQueen)
|
val boardQueen = Board(piecesForQueen)
|
||||||
val qResult = PgnParser.parseAlgebraicMove("Qd4", boardQueen, GameHistory.empty, Color.White)
|
val qResult = PgnParser.parseAlgebraicMove("Qd4", GameContext.initial.withBoard(boardQueen), Color.White)
|
||||||
qResult.isDefined shouldBe true
|
qResult.isDefined shouldBe true
|
||||||
|
|
||||||
// King move
|
// King move
|
||||||
@@ -217,7 +212,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val boardKing = Board(piecesForKing)
|
val boardKing = Board(piecesForKing)
|
||||||
val kResult = PgnParser.parseAlgebraicMove("Ke2", boardKing, GameHistory.empty, Color.White)
|
val kResult = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(boardKing), Color.White)
|
||||||
kResult.isDefined shouldBe true
|
kResult.isDefined shouldBe true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +225,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
val lastMove = game.get.moves.last
|
val lastMove = game.get.moves.last
|
||||||
lastMove.castleSide shouldBe Some(CastleSide.Queenside)
|
lastMove.castleSide shouldBe Some("Queenside")
|
||||||
lastMove.from shouldBe Square(File.E, Rank.R1)
|
lastMove.from shouldBe Square(File.E, Rank.R1)
|
||||||
lastMove.to shouldBe Square(File.C, Rank.R1)
|
lastMove.to shouldBe Square(File.C, Rank.R1)
|
||||||
}
|
}
|
||||||
@@ -245,7 +240,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
val lastMove = game.get.moves.last
|
val lastMove = game.get.moves.last
|
||||||
lastMove.castleSide shouldBe Some(CastleSide.Queenside)
|
lastMove.castleSide shouldBe Some("Queenside")
|
||||||
lastMove.from shouldBe Square(File.E, Rank.R8)
|
lastMove.from shouldBe Square(File.E, Rank.R8)
|
||||||
lastMove.to shouldBe Square(File.C, Rank.R8)
|
lastMove.to shouldBe Square(File.C, Rank.R8)
|
||||||
}
|
}
|
||||||
@@ -275,10 +270,9 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val board = Board(pieces)
|
val board = Board(pieces)
|
||||||
val history = GameHistory.empty
|
|
||||||
|
|
||||||
// "Rae4" - Rook from a-file to e4; disambig = "a", clean.head = 'R' uppercase
|
// "Rae4" - Rook from a-file to e4; disambig = "a", clean.head = 'R' uppercase
|
||||||
val result = PgnParser.parseAlgebraicMove("Rae4", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("Rae4", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.isDefined shouldBe true
|
result.isDefined shouldBe true
|
||||||
result.get.from shouldBe Square(File.A, Rank.R4)
|
result.get.from shouldBe Square(File.A, Rank.R4)
|
||||||
result.get.to shouldBe Square(File.E, Rank.R4)
|
result.get.to shouldBe Square(File.E, Rank.R4)
|
||||||
@@ -288,13 +282,12 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
// 'Z' is not a valid piece letter - the regex clean should return None
|
// 'Z' is not a valid piece letter - the regex clean should return None
|
||||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
val history = GameHistory.empty
|
|
||||||
|
|
||||||
// "Ze4" - Z is not a valid piece, charToPieceType('Z') returns None
|
// "Ze4" - Z is not a valid piece, charToPieceType('Z') returns None
|
||||||
// The result will be None because requiredPieceType is None and filtering by None.forall = true
|
// The result will be None because requiredPieceType is None and filtering by None.forall = true
|
||||||
// so it finds any piece that can reach e4, but since clean="Ze4" -> destStr="e4", disambig="Z"
|
// so it finds any piece that can reach e4, but since clean="Ze4" -> destStr="e4", disambig="Z"
|
||||||
// disambig.head.isUpper so charToPieceType('Z') is called
|
// disambig.head.isUpper so charToPieceType('Z') is called
|
||||||
val result = PgnParser.parseAlgebraicMove("Ze4", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("Ze4", GameContext.initial.withBoard(board), Color.White)
|
||||||
// With None piece type, forall(pt => ...) is vacuously true so any piece reaching e4 is candidate
|
// With None piece type, forall(pt => ...) is vacuously true so any piece reaching e4 is candidate
|
||||||
// But there's no piece named Z so requiredPieceType=None, meaning any piece can match
|
// But there's no piece named Z so requiredPieceType=None, meaning any piece can match
|
||||||
// This tests that charToPieceType('Z') returns None without crashing
|
// This tests that charToPieceType('Z') returns None without crashing
|
||||||
@@ -306,9 +299,8 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
// This exercises line 97 (else if clean.head.isUpper) and line 152 (case _ => None)
|
// This exercises line 97 (else if clean.head.isUpper) and line 152 (case _ => None)
|
||||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
val history = GameHistory.empty
|
|
||||||
// 'E' is not a valid piece type but we still get a result since requiredPieceType is None
|
// 'E' is not a valid piece type but we still get a result since requiredPieceType is None
|
||||||
val result = PgnParser.parseAlgebraicMove("E4", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("E4", GameContext.initial.withBoard(board), Color.White)
|
||||||
// Result may be defined (pawn that can reach e4) or None; main goal is no crash and line coverage
|
// Result may be defined (pawn that can reach e4) or None; main goal is no crash and line coverage
|
||||||
result should not be null // just verifies code path executes without exception
|
result should not be null // just verifies code path executes without exception
|
||||||
}
|
}
|
||||||
@@ -324,12 +316,11 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val board = Board(pieces)
|
val board = Board(pieces)
|
||||||
val history = GameHistory.empty
|
|
||||||
|
|
||||||
// "R9d1" - clean = "R9d1", destStr = "d1", disambig = "R9"
|
// "R9d1" - clean = "R9d1", destStr = "d1", disambig = "R9"
|
||||||
// disambig.head = 'R' is upper -> charToPieceType('R') = Rook, hint = "9"
|
// disambig.head = 'R' is upper -> charToPieceType('R') = Rook, hint = "9"
|
||||||
// matchesHint called with hint "9" -> '9' not in a-h, not in 1-8 -> else true
|
// matchesHint called with hint "9" -> '9' not in a-h, not in 1-8 -> else true
|
||||||
val result = PgnParser.parseAlgebraicMove("R9d1", board, history, Color.White)
|
val result = PgnParser.parseAlgebraicMove("R9d1", GameContext.initial.withBoard(board), Color.White)
|
||||||
// Should find a rook (hint "9" matches everything)
|
// Should find a rook (hint "9" matches everything)
|
||||||
result.isDefined shouldBe true
|
result.isDefined shouldBe true
|
||||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
result.get.to shouldBe Square(File.D, Rank.R1)
|
||||||
@@ -337,28 +328,28 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
|
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
val result = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.isDefined should be (true)
|
result.isDefined should be (true)
|
||||||
result.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
result.get.to should be (Square(File.E, Rank.R8))
|
result.get.to should be (Square(File.E, Rank.R8))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseAlgebraicMove preserves promotion to Rook") {
|
test("parseAlgebraicMove preserves promotion to Rook") {
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White)
|
val result = PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.get.promotionPiece should be (Some(PromotionPiece.Rook))
|
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseAlgebraicMove preserves promotion to Bishop") {
|
test("parseAlgebraicMove preserves promotion to Bishop") {
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White)
|
val result = PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.get.promotionPiece should be (Some(PromotionPiece.Bishop))
|
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseAlgebraicMove preserves promotion to Knight") {
|
test("parseAlgebraicMove preserves promotion to Knight") {
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White)
|
val result = PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
|
||||||
result.get.promotionPiece should be (Some(PromotionPiece.Knight))
|
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parsePgn applies promoted piece to board for subsequent moves") {
|
test("parsePgn applies promoted piece to board for subsequent moves") {
|
||||||
@@ -370,9 +361,9 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King)
|
Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King)
|
||||||
)
|
)
|
||||||
val board = Board(pieces)
|
val board = Board(pieces)
|
||||||
val move = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
val move = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||||
move.isDefined should be (true)
|
move.isDefined should be (true)
|
||||||
move.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
move.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
// After applying the promotion the square e8 should hold a White Queen
|
// After applying the promotion the square e8 should hold a White Queen
|
||||||
val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to)
|
val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to)
|
||||||
val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen))
|
val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen))
|
||||||
@@ -380,32 +371,29 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") {
|
test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") {
|
||||||
// This test exercises lines 53-58 in PgnParser.parseMovesText which contain
|
// Exercises the promotion piece type branches in PgnParser.parseMovesText / toHistoryMove
|
||||||
// the pattern match over PromotionPiece for Queen, Rook, Bishop, Knight
|
// White pawn advances via capture chain (a4xb5, b5xc6 e.p., c6-c7, c7xd8) and promotes
|
||||||
val pgn = """[Event "Promotion Test"]
|
val baseSequence = "1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8="
|
||||||
[White "A"]
|
for (piece, expected) <- List(
|
||||||
[Black "B"]
|
"Q" -> PromotionPiece.Queen,
|
||||||
|
"R" -> PromotionPiece.Rook,
|
||||||
1. a2a3 h7h5 2. a3a4 h5h4 3. a4a5 h4h3 4. a5a6 h3h2 5. a6a7 h2h1=Q 6. a7a8=R 1-0
|
"B" -> PromotionPiece.Bishop,
|
||||||
"""
|
"N" -> PromotionPiece.Knight
|
||||||
val game = PgnParser.parsePgn(pgn)
|
) do
|
||||||
|
val pgn = s"""[Event "Promotion Test"]\n\n${baseSequence}$piece\n"""
|
||||||
game.isDefined shouldBe true
|
val game = PgnParser.parsePgn(pgn)
|
||||||
// Move 10 is h2h1=Q (black pawn promotes to queen)
|
game.isDefined shouldBe true
|
||||||
val blackPromotionToQ = game.get.moves(9) // 0-indexed
|
game.get.moves should not be empty
|
||||||
blackPromotionToQ.promotionPiece shouldBe Some(PromotionPiece.Queen)
|
game.get.moves.last.promotionPiece shouldBe Some(expected)
|
||||||
|
|
||||||
// Move 11 is a7a8=R (white pawn promotes to rook)
|
|
||||||
val whitePromotionToR = game.get.moves(10)
|
|
||||||
whitePromotionToR.promotionPiece shouldBe Some(PromotionPiece.Rook)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseAlgebraicMove promotion with Rook through full PGN parse") {
|
test("parseAlgebraicMove promotion with Rook through full PGN parse") {
|
||||||
|
// White pawn advances via capture chain and promotes by capturing black queen on d8
|
||||||
val pgn = """[Event "Test"]
|
val pgn = """[Event "Test"]
|
||||||
[White "A"]
|
[White "A"]
|
||||||
[Black "B"]
|
[Black "B"]
|
||||||
|
|
||||||
1. a2a3 h7h6 2. a3a4 h6h5 3. a4a5 h5h4 4. a5a6 h4h3 5. a6a7 h3h2 6. a7a8=R
|
1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=R
|
||||||
"""
|
"""
|
||||||
val game = PgnParser.parsePgn(pgn)
|
val game = PgnParser.parsePgn(pgn)
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
@@ -418,7 +406,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
[White "A"]
|
[White "A"]
|
||||||
[Black "B"]
|
[Black "B"]
|
||||||
|
|
||||||
1. b2b3 h7h6 2. b3b4 h6h5 3. b4b5 h5h4 4. b5b6 h4h3 5. b6b7 h3h2 6. b7b8=B
|
1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=B
|
||||||
"""
|
"""
|
||||||
val game = PgnParser.parsePgn(pgn)
|
val game = PgnParser.parsePgn(pgn)
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
@@ -431,7 +419,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
[White "A"]
|
[White "A"]
|
||||||
[Black "B"]
|
[Black "B"]
|
||||||
|
|
||||||
1. c2c3 h7h6 2. c3c4 h6h5 3. c4c5 h5h4 4. c5c6 h4h3 5. c6c7 h3h2 6. c7c8=N
|
1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=N
|
||||||
"""
|
"""
|
||||||
val game = PgnParser.parsePgn(pgn)
|
val game = PgnParser.parsePgn(pgn)
|
||||||
game.isDefined shouldBe true
|
game.isDefined shouldBe true
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.nowchess.chess.notation
|
|||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
import de.nowchess.api.game.{GameHistory, HistoryMove}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
"""
|
"""
|
||||||
PgnParser.validatePgn(pgn) match
|
PgnParser.validatePgn(pgn) match
|
||||||
case Right(game) =>
|
case Right(game) =>
|
||||||
game.moves.last.castleSide shouldBe Some(CastleSide.Kingside)
|
game.moves.last.castleSide shouldBe Some("Kingside")
|
||||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||||
|
|
||||||
test("validatePgn: castling when not legal returns Left"):
|
test("validatePgn: castling when not legal returns Left"):
|
||||||
@@ -93,7 +93,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
"""
|
"""
|
||||||
PgnParser.validatePgn(pgn) match
|
PgnParser.validatePgn(pgn) match
|
||||||
case Right(game) =>
|
case Right(game) =>
|
||||||
game.moves.last.castleSide shouldBe Some(CastleSide.Queenside)
|
game.moves.last.castleSide shouldBe Some("Queenside")
|
||||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||||
|
|
||||||
test("validatePgn: disambiguation with two rooks is accepted"):
|
test("validatePgn: disambiguation with two rooks is accepted"):
|
||||||
|
|||||||
+2
-6
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.observer
|
package de.nowchess.chess.observer
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
@@ -21,11 +21,7 @@ class ObservableThreadSafetyTest extends AnyFunSuite with Matchers:
|
|||||||
lastEvent = Some(event)
|
lastEvent = Some(event)
|
||||||
|
|
||||||
private def createTestEvent(): GameEvent =
|
private def createTestEvent(): GameEvent =
|
||||||
BoardResetEvent(
|
BoardResetEvent(context = GameContext.initial)
|
||||||
board = Board.initial,
|
|
||||||
history = GameHistory.empty,
|
|
||||||
turn = Color.White
|
|
||||||
)
|
|
||||||
|
|
||||||
test("Observable is thread-safe for concurrent subscribe and notify"):
|
test("Observable is thread-safe for concurrent subscribe and notify"):
|
||||||
val observable = new TestObservable()
|
val observable = new TestObservable()
|
||||||
|
|||||||
@@ -31,3 +31,9 @@ trait RuleSet:
|
|||||||
|
|
||||||
/** True if halfMoveClock >= 100 (50-move rule). */
|
/** True if halfMoveClock >= 100 (50-move rule). */
|
||||||
def isFiftyMoveRule(context: GameContext): Boolean
|
def isFiftyMoveRule(context: GameContext): Boolean
|
||||||
|
|
||||||
|
/** Apply a legal move to produce the next game context.
|
||||||
|
* Handles all special move types: castling, en passant, promotion.
|
||||||
|
* Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||||
|
*/
|
||||||
|
def applyMove(context: GameContext, move: Move): GameContext
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.rules
|
package de.nowchess.rules
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Board, Color, Square, PieceType, Piece}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square, PieceType, Piece}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import scala.annotation.tailrec
|
import scala.annotation.tailrec
|
||||||
|
|
||||||
@@ -274,6 +274,95 @@ object StandardRules extends RuleSet:
|
|||||||
val nextContext = context.withBoard(nextBoard)
|
val nextContext = context.withBoard(nextBoard)
|
||||||
isCheck(nextContext)
|
isCheck(nextContext)
|
||||||
|
|
||||||
|
// ── Move application ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
override def applyMove(context: GameContext, move: Move): GameContext =
|
||||||
|
val color = context.turn
|
||||||
|
val board = context.board
|
||||||
|
|
||||||
|
val newBoard = move.moveType match
|
||||||
|
case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
|
||||||
|
case MoveType.CastleQueenside => applyCastle(board, color, kingside = false)
|
||||||
|
case MoveType.EnPassant => applyEnPassant(board, move, color)
|
||||||
|
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
|
||||||
|
case MoveType.Normal => board.applyMove(move)
|
||||||
|
|
||||||
|
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
||||||
|
val newEnPassantSquare = computeEnPassantSquare(board, move, color)
|
||||||
|
val isCapture = board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant
|
||||||
|
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
||||||
|
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||||
|
|
||||||
|
context
|
||||||
|
.withBoard(newBoard)
|
||||||
|
.withTurn(color.opposite)
|
||||||
|
.withCastlingRights(newCastlingRights)
|
||||||
|
.withEnPassantSquare(newEnPassantSquare)
|
||||||
|
.withHalfMoveClock(newClock)
|
||||||
|
.withMove(move)
|
||||||
|
|
||||||
|
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
||||||
|
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||||
|
val (kingFrom, kingTo, rookFrom, rookTo) =
|
||||||
|
if kingside then
|
||||||
|
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||||
|
else
|
||||||
|
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||||
|
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
||||||
|
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
||||||
|
board
|
||||||
|
.removed(kingFrom).removed(rookFrom)
|
||||||
|
.updated(kingTo, king)
|
||||||
|
.updated(rookTo, rook)
|
||||||
|
|
||||||
|
private def applyEnPassant(board: Board, move: Move, color: Color): Board =
|
||||||
|
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||||
|
val capturedSquare = Square(move.to.file, capturedRank)
|
||||||
|
board.applyMove(move).removed(capturedSquare)
|
||||||
|
|
||||||
|
private def applyPromotion(board: Board, move: Move, color: Color, pp: PromotionPiece): Board =
|
||||||
|
val promotedType = pp match
|
||||||
|
case PromotionPiece.Queen => PieceType.Queen
|
||||||
|
case PromotionPiece.Rook => PieceType.Rook
|
||||||
|
case PromotionPiece.Bishop => PieceType.Bishop
|
||||||
|
case PromotionPiece.Knight => PieceType.Knight
|
||||||
|
board.removed(move.from).updated(move.to, Piece(color, promotedType))
|
||||||
|
|
||||||
|
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
|
||||||
|
val piece = board.pieceAt(move.from)
|
||||||
|
val isKingMove = piece.exists(_.pieceType == PieceType.King)
|
||||||
|
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
|
||||||
|
|
||||||
|
// Helper to check if a square is a rook's starting square
|
||||||
|
val whiteKingsideRook = Square(File.H, Rank.R1)
|
||||||
|
val whiteQueensideRook = Square(File.A, Rank.R1)
|
||||||
|
val blackKingsideRook = Square(File.H, Rank.R8)
|
||||||
|
val blackQueensideRook = Square(File.A, Rank.R8)
|
||||||
|
|
||||||
|
var r = rights
|
||||||
|
if isKingMove then r = r.revokeColor(color)
|
||||||
|
else if isRookMove then
|
||||||
|
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||||
|
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||||
|
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||||
|
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||||
|
// Also revoke if a rook is captured
|
||||||
|
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||||
|
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||||
|
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||||
|
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||||
|
r
|
||||||
|
|
||||||
|
private def computeEnPassantSquare(board: Board, move: Move, color: Color): Option[Square] =
|
||||||
|
val piece = board.pieceAt(move.from)
|
||||||
|
val isDoublePawnPush = piece.exists(_.pieceType == PieceType.Pawn) &&
|
||||||
|
math.abs(move.to.rank.ordinal - move.from.rank.ordinal) == 2
|
||||||
|
if isDoublePawnPush then
|
||||||
|
// EP square is the square the pawn passed through
|
||||||
|
val epRankOrd = (move.from.rank.ordinal + move.to.rank.ordinal) / 2
|
||||||
|
Some(Square(move.from.file, Rank.values(epRankOrd)))
|
||||||
|
else None
|
||||||
|
|
||||||
// ── Insufficient material ──────────────────────────────────────────
|
// ── Insufficient material ──────────────────────────────────────────
|
||||||
|
|
||||||
private def insufficientMaterial(board: Board): Boolean =
|
private def insufficientMaterial(board: Board): Boolean =
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
implementation(project(":modules:core"))
|
implementation(project(":modules:core"))
|
||||||
|
implementation(project(":modules:rule"))
|
||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
|
|
||||||
// ScalaFX dependencies
|
// ScalaFX dependencies
|
||||||
|
|||||||
@@ -269,7 +269,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
|
|
||||||
private def doPgnImport(): Unit =
|
private def doPgnImport(): Unit =
|
||||||
showMessage("PGN import temporarily disabled during NCS-22 refactoring")
|
showMessage("PGN import temporarily disabled during NCS-22 refactoring")
|
||||||
}
|
|
||||||
|
|
||||||
private def showCopyDialog(title: String, content: String): Unit =
|
private def showCopyDialog(title: String, content: String): Unit =
|
||||||
val area = new javafx.scene.control.TextArea(content)
|
val area = new javafx.scene.control.TextArea(content)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package de.nowchess.ui
|
|
||||||
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
|
||||||
|
|
||||||
class MainTest extends AnyFunSuite with Matchers {
|
|
||||||
|
|
||||||
test("main should execute and quit immediately when fed 'quit'") {
|
|
||||||
val in = new ByteArrayInputStream("quit\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
Main.main(Array.empty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
output should include ("Game over. Goodbye!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package de.nowchess.ui.terminal
|
|
||||||
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
|
||||||
import de.nowchess.chess.engine.GameEngine
|
|
||||||
import de.nowchess.chess.observer.*
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
|
|
||||||
class TerminalUITest extends AnyFunSuite with Matchers {
|
|
||||||
|
|
||||||
test("TerminalUI should start, print initial state, and correctly respond to 'q'") {
|
|
||||||
val in = new ByteArrayInputStream("q\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
output should include("White's turn.")
|
|
||||||
output should include("Game over. Goodbye!")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI should ignore empty inputs and re-print prompt") {
|
|
||||||
val in = new ByteArrayInputStream("\nq\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
// Prompt appears three times: Initial, after empty, on exit.
|
|
||||||
output.split("White's turn.").length should be > 2
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI should explicitly handle empty input by re-prompting") {
|
|
||||||
val in = new ByteArrayInputStream("\n\nq\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
// With two empty inputs, prompt should appear at least 4 times:
|
|
||||||
// 1. Initial board display
|
|
||||||
// 2. After first empty input
|
|
||||||
// 3. After second empty input
|
|
||||||
// 4. Before quit
|
|
||||||
val promptCount = output.split("White's turn.").length
|
|
||||||
promptCount should be >= 4
|
|
||||||
output should include("Game over. Goodbye!")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI printPrompt should include undo and redo hints if engine returns true") {
|
|
||||||
val in = new ByteArrayInputStream("\nq\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
val engine = new GameEngine() {
|
|
||||||
// Stub engine to force undo/redo to true
|
|
||||||
override def canUndo: Boolean = true
|
|
||||||
override def canRedo: Boolean = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
output should include("[undo]")
|
|
||||||
output should include("[redo]")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI onGameEvent should properly format InvalidMoveEvent") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(InvalidMoveEvent(Board(Map.empty), GameHistory(), Color.Black, "Invalid move format"))
|
|
||||||
}
|
|
||||||
|
|
||||||
out.toString should include("⚠️")
|
|
||||||
out.toString should include("Invalid move format")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI onGameEvent should properly format CheckDetectedEvent") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(CheckDetectedEvent(Board(Map.empty), GameHistory(), Color.Black))
|
|
||||||
}
|
|
||||||
|
|
||||||
out.toString should include("Black is in check!")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI onGameEvent should properly format CheckmateEvent") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(CheckmateEvent(Board(Map.empty), GameHistory(), Color.Black, Color.White))
|
|
||||||
}
|
|
||||||
|
|
||||||
val ostr = out.toString
|
|
||||||
ostr should include("Checkmate! White wins.")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI onGameEvent should properly format StalemateEvent") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(StalemateEvent(Board(Map.empty), GameHistory(), Color.Black))
|
|
||||||
}
|
|
||||||
|
|
||||||
out.toString should include("Stalemate! The game is a draw.")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI onGameEvent should properly format BoardResetEvent") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(BoardResetEvent(Board(Map.empty), GameHistory(), Color.White))
|
|
||||||
}
|
|
||||||
|
|
||||||
out.toString should include("Board has been reset to initial position.")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI onGameEvent should properly format MoveExecutedEvent with capturing piece") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(MoveExecutedEvent(Board(Map.empty), GameHistory(), Color.Black, "A1", "A8", Some("Knight(White)")))
|
|
||||||
}
|
|
||||||
|
|
||||||
out.toString should include("Captured: Knight(White) on A8") // Depending on how piece/coord serialize
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI processes valid move input via processUserInput") {
|
|
||||||
val in = new ByteArrayInputStream("e2e4\nq\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
output should include("White's turn.")
|
|
||||||
output should include("Game over. Goodbye!")
|
|
||||||
// The move should have been processed and the board displayed
|
|
||||||
engine.turn shouldBe Color.Black
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI shows promotion prompt on PromotionRequiredEvent") {
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.onGameEvent(PromotionRequiredEvent(
|
|
||||||
Board(Map.empty), GameHistory(), Color.White,
|
|
||||||
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
out.toString should include("Promote to")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI routes promotion choice to engine.completePromotion") {
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
|
|
||||||
var capturedPiece: Option[PromotionPiece] = None
|
|
||||||
|
|
||||||
val engine = new GameEngine() {
|
|
||||||
override def processUserInput(rawInput: String): Unit =
|
|
||||||
if rawInput.trim == "e7e8" then
|
|
||||||
notifyObservers(PromotionRequiredEvent(
|
|
||||||
Board(Map.empty), GameHistory.empty, Color.White,
|
|
||||||
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
||||||
))
|
|
||||||
override def completePromotion(piece: PromotionPiece): Unit =
|
|
||||||
capturedPiece = Some(piece)
|
|
||||||
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
|
||||||
}
|
|
||||||
|
|
||||||
val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
capturedPiece should be(Some(PromotionPiece.Queen))
|
|
||||||
out.toString should include("Promote to")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI re-prompts on invalid promotion choice") {
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
|
|
||||||
var capturedPiece: Option[PromotionPiece] = None
|
|
||||||
|
|
||||||
val engine = new GameEngine() {
|
|
||||||
override def processUserInput(rawInput: String): Unit =
|
|
||||||
if rawInput.trim == "e7e8" then
|
|
||||||
notifyObservers(PromotionRequiredEvent(
|
|
||||||
Board(Map.empty), GameHistory.empty, Color.White,
|
|
||||||
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
||||||
))
|
|
||||||
override def completePromotion(piece: PromotionPiece): Unit =
|
|
||||||
capturedPiece = Some(piece)
|
|
||||||
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
|
||||||
}
|
|
||||||
|
|
||||||
// "x" is invalid, then "r" for rook
|
|
||||||
val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
capturedPiece should be(Some(PromotionPiece.Rook))
|
|
||||||
out.toString should include("Invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI routes Bishop promotion choice to engine.completePromotion") {
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
|
|
||||||
var capturedPiece: Option[PromotionPiece] = None
|
|
||||||
|
|
||||||
val engine = new GameEngine() {
|
|
||||||
override def processUserInput(rawInput: String): Unit =
|
|
||||||
if rawInput.trim == "e7e8" then
|
|
||||||
notifyObservers(PromotionRequiredEvent(
|
|
||||||
Board(Map.empty), GameHistory.empty, Color.White,
|
|
||||||
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
||||||
))
|
|
||||||
override def completePromotion(piece: PromotionPiece): Unit =
|
|
||||||
capturedPiece = Some(piece)
|
|
||||||
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
|
||||||
}
|
|
||||||
|
|
||||||
val in = new ByteArrayInputStream("e7e8\nb\nquit\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
capturedPiece should be(Some(PromotionPiece.Bishop))
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TerminalUI routes Knight promotion choice to engine.completePromotion") {
|
|
||||||
import de.nowchess.api.move.PromotionPiece
|
|
||||||
|
|
||||||
var capturedPiece: Option[PromotionPiece] = None
|
|
||||||
|
|
||||||
val engine = new GameEngine() {
|
|
||||||
override def processUserInput(rawInput: String): Unit =
|
|
||||||
if rawInput.trim == "e7e8" then
|
|
||||||
notifyObservers(PromotionRequiredEvent(
|
|
||||||
Board(Map.empty), GameHistory.empty, Color.White,
|
|
||||||
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
||||||
))
|
|
||||||
override def completePromotion(piece: PromotionPiece): Unit =
|
|
||||||
capturedPiece = Some(piece)
|
|
||||||
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
|
||||||
}
|
|
||||||
|
|
||||||
val in = new ByteArrayInputStream("e7e8\nn\nquit\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
val ui = new TerminalUI(engine)
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
ui.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
capturedPiece should be(Some(PromotionPiece.Knight))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user