feat: NCS-17 Implement basic ScalaFX UI (#14)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com> Reviewed-on: #14 Co-authored-by: Janis <janis.e.20@gmx.de> Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #14.
This commit is contained in:
@@ -92,9 +92,10 @@ object GameController:
|
||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
||||
else (b, cap)
|
||||
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
|
||||
val pieceType = board.pieceAt(from).map(_.pieceType).getOrElse(PieceType.Pawn)
|
||||
val wasPawnMove = pieceType == PieceType.Pawn
|
||||
val wasCapture = captured.isDefined
|
||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
|
||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture, pieceType = pieceType)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
||||
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
|
||||
|
||||
/** 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.
|
||||
@@ -212,6 +213,60 @@ class GameEngine(
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
|
||||
}
|
||||
|
||||
/** Validate and load a PGN string.
|
||||
* 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 {
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Left(err) =>
|
||||
Left(err)
|
||||
case Right(game) =>
|
||||
val initialBoardBeforeLoad = currentBoard
|
||||
val initialHistoryBeforeLoad = currentHistory
|
||||
val initialTurnBeforeLoad = currentTurn
|
||||
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
|
||||
var error: Option[String] = None
|
||||
import scala.util.control.Breaks._
|
||||
breakable {
|
||||
game.moves.foreach { move =>
|
||||
handleParsedMove(move.from, move.to, s"${move.from}${move.to}")
|
||||
move.promotionPiece.foreach(completePromotion)
|
||||
|
||||
// If the move failed to execute properly, stop and report
|
||||
// (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()
|
||||
}
|
||||
}
|
||||
|
||||
error match
|
||||
case Some(err) =>
|
||||
currentBoard = initialBoardBeforeLoad
|
||||
currentHistory = initialHistoryBeforeLoad
|
||||
currentTurn = initialTurnBeforeLoad
|
||||
Left(err)
|
||||
case None =>
|
||||
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
|
||||
Right(())
|
||||
}
|
||||
|
||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||
def loadPosition(board: Board, history: GameHistory, turn: Color): Unit = synchronized {
|
||||
currentBoard = board
|
||||
currentHistory = history
|
||||
currentTurn = turn
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||
}
|
||||
|
||||
/** Reset the board to initial position. */
|
||||
def reset(): Unit = synchronized {
|
||||
currentBoard = Board.initial
|
||||
@@ -232,11 +287,12 @@ class GameEngine(
|
||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||
(cmd: @unchecked) match
|
||||
case moveCmd: MoveCommand =>
|
||||
val notation = currentHistory.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
||||
invoker.undo()
|
||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||
notifyObservers(MoveUndoneEvent(currentBoard, currentHistory, currentTurn, notation))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
||||
|
||||
@@ -248,7 +304,9 @@ class GameEngine(
|
||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
||||
updateGameState(nb, nh, nt)
|
||||
invoker.redo()
|
||||
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, cap, nt)
|
||||
val notation = nh.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveRedoneEvent(currentBoard, currentHistory, currentTurn, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.board.{PieceType, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
|
||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
||||
@@ -8,7 +8,9 @@ case class HistoryMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide],
|
||||
promotionPiece: Option[PromotionPiece] = None
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
pieceType: PieceType = PieceType.Pawn,
|
||||
isCapture: Boolean = false
|
||||
)
|
||||
|
||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
||||
@@ -37,10 +39,11 @@ case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int
|
||||
castleSide: Option[CastleSide] = None,
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
wasPawnMove: Boolean = false,
|
||||
wasCapture: Boolean = false
|
||||
wasCapture: Boolean = false,
|
||||
pieceType: PieceType = PieceType.Pawn
|
||||
): GameHistory =
|
||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
|
||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece, pieceType, wasCapture), newClock)
|
||||
|
||||
object GameHistory:
|
||||
val empty: GameHistory = GameHistory()
|
||||
|
||||
@@ -73,7 +73,7 @@ object MoveValidator:
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
val dir = if color == Color.White then 1 else -1
|
||||
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
|
||||
val startRank = if color == Color.White then Rank.R2.ordinal else Rank.R7.ordinal
|
||||
|
||||
val oneStep = squareAt(fi, ri + dir)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.board.{PieceType, *}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
||||
|
||||
@@ -29,16 +29,26 @@ object PgnExporter:
|
||||
else if moveText.isEmpty then headerLines
|
||||
else s"$headerLines\n\n$moveText"
|
||||
|
||||
/** Convert a HistoryMove to algebraic notation. */
|
||||
private def moveToAlgebraic(move: HistoryMove): String =
|
||||
/** Convert a HistoryMove to Standard Algebraic Notation. */
|
||||
def moveToAlgebraic(move: HistoryMove): String =
|
||||
move.castleSide match
|
||||
case Some(CastleSide.Kingside) => "O-O"
|
||||
case Some(CastleSide.Queenside) => "O-O-O"
|
||||
case None =>
|
||||
val base = s"${move.from}${move.to}"
|
||||
move.promotionPiece match
|
||||
case Some(PromotionPiece.Queen) => s"$base=Q"
|
||||
case Some(PromotionPiece.Rook) => s"$base=R"
|
||||
case Some(PromotionPiece.Bishop) => s"$base=B"
|
||||
case Some(PromotionPiece.Knight) => s"$base=N"
|
||||
case None => base
|
||||
val dest = move.to.toString
|
||||
val capStr = if move.isCapture then "x" else ""
|
||||
val promSuffix = move.promotionPiece match
|
||||
case Some(PromotionPiece.Queen) => "=Q"
|
||||
case Some(PromotionPiece.Rook) => "=R"
|
||||
case Some(PromotionPiece.Bishop) => "=B"
|
||||
case Some(PromotionPiece.Knight) => "=N"
|
||||
case None => ""
|
||||
move.pieceType match
|
||||
case PieceType.Pawn =>
|
||||
if move.isCapture then s"${move.from.file.toString.toLowerCase}x$dest$promSuffix"
|
||||
else s"$dest$promSuffix"
|
||||
case PieceType.Knight => s"N$capStr$dest$promSuffix"
|
||||
case PieceType.Bishop => s"B$capStr$dest$promSuffix"
|
||||
case PieceType.Rook => s"R$capStr$dest$promSuffix"
|
||||
case PieceType.Queen => s"Q$capStr$dest$promSuffix"
|
||||
case PieceType.King => s"K$capStr$dest$promSuffix"
|
||||
|
||||
@@ -12,6 +12,16 @@ case class PgnGame(
|
||||
|
||||
object PgnParser:
|
||||
|
||||
/** Strictly validate a PGN text.
|
||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
def parsePgn(pgn: String): Option[PgnGame] =
|
||||
@@ -79,11 +89,11 @@ object PgnParser:
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside)))
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside), pieceType = PieceType.King))
|
||||
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside)))
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside), pieceType = PieceType.King))
|
||||
|
||||
case _ =>
|
||||
parseRegularMove(notation, board, history, color)
|
||||
@@ -143,8 +153,10 @@ object PgnParser:
|
||||
if hint.isEmpty then byPiece
|
||||
else byPiece.filter(from => matchesHint(from, hint))
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
|
||||
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). */
|
||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||
@@ -173,3 +185,83 @@ object PgnParser:
|
||||
case 'Q' => Some(PieceType.Queen)
|
||||
case 'K' => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
||||
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])]) {
|
||||
case (acc, token) =>
|
||||
acc.flatMap { case (board, history, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((board, history, color, moves))
|
||||
else
|
||||
strictParseAlgebraicMove(token, board, history, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val newBoard = applyMoveToBoard(board, move, color)
|
||||
val newHistory = history.addMove(move)
|
||||
Right((newBoard, newHistory, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._4)
|
||||
|
||||
/** 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(CastleSide.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(CastleSide.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))
|
||||
}
|
||||
|
||||
@@ -81,6 +81,32 @@ case class DrawClaimedEvent(
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||
case class MoveUndoneEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
pgnNotation: String
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||
case class MoveRedoneEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||
case class PgnLoadedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
Reference in New Issue
Block a user