feat: NCS-17 Implement basic ScalaFX UI (#14)
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:
2026-04-01 22:48:30 +02:00
committed by Janis
parent 9fb743d135
commit 3ff80318b4
75 changed files with 1097 additions and 48 deletions
@@ -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