feat: NCS-25 Add linters to keep quality up
Build & Test (NowChessSystems) TeamCity build finished

This commit is contained in:
2026-04-12 18:51:39 +02:00
parent 3ecb2c9d66
commit eb8cc060cf
67 changed files with 1416 additions and 1200 deletions
@@ -4,9 +4,9 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher
/** Application entry point - starts both GUI and Terminal UI for the chess game.
* Both views subscribe to the same GameEngine via Observer pattern.
*/
/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
* GameEngine via Observer pattern.
*/
object Main:
def main(args: Array[String]): Unit =
// Create the core game engine (single source of truth)
@@ -18,4 +18,3 @@ object Main:
// Create and start the terminal UI (blocks on main thread)
val tui = new TerminalUI(engine)
tui.start()
@@ -18,32 +18,31 @@ import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.{GameContextExport, GameContextImport}
/** ScalaFX chess board view that displays the game state.
* Uses chess sprites and color palette.
* Handles user interactions (clicks) and sends moves to GameEngine.
*/
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
* interactions (clicks) and sends moves to GameEngine.
*/
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
private val squareSize = 70.0
private val squareSize = 70.0
private val comicSansFontFamily = "Comic Sans MS"
private val boardGrid = new GridPane()
private val boardGrid = new GridPane()
private val messageLabel = new Label {
text = "Welcome!"
font = Font.font(comicSansFontFamily, 16)
padding = Insets(10)
}
private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn
private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn
private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized
// Initialize UI
initializeBoard()
top = new VBox {
padding = Insets(10)
spacing = 5
@@ -54,17 +53,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 24)
style = "-fx-font-weight: bold;"
},
messageLabel
messageLabel,
)
}
center = new VBox {
padding = Insets(20)
alignment = Pos.Center
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
children = boardGrid
}
bottom = new VBox {
padding = Insets(10)
spacing = 8
@@ -82,8 +81,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
disable = !engine.canUndo
}
undoButton
},
{
}, {
redoButton = new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
@@ -96,7 +94,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12)
onAction = _ => engine.reset()
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
}
},
)
},
new HBox {
@@ -122,17 +120,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doPgnImport()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
}
},
)
}
},
)
}
private def initializeBoard(): Unit =
boardGrid.padding = Insets(5)
boardGrid.hgap = 0
boardGrid.vgap = 0
// Create 8x8 board with rank/file labels
for
rank <- 0 until 8
@@ -141,13 +139,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val square = createSquare(rank, file)
squareViews((rank, file)) = square
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
updateBoard(currentBoard, currentTurn)
private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0
val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle {
width = squareSize
height = squareSize
@@ -155,21 +153,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcWidth = 8
arcHeight = 8
}
val square = new StackPane {
children = Seq(bgRect)
onMouseClicked = _ => handleSquareClick(rank, file)
style = "-fx-cursor: hand;"
}
square
private def handleSquareClick(rank: Int, file: Int): Unit =
if engine.isPendingPromotion then
return // Don't allow moves during promotion
if engine.isPendingPromotion then return // Don't allow moves during promotion
val clickedSquare = Square(File.values(file), Rank.values(rank))
selectedSquare match
case None =>
// First click - select piece if it belongs to current player
@@ -178,13 +175,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
val legalDests = engine.ruleSet
.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
case Some(fromSquare) =>
// Second click - attempt move
if clickedSquare == fromSquare then
@@ -193,24 +191,24 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateBoard(currentBoard, currentTurn)
else
// Try to move
val moveStr = s"${fromSquare}$clickedSquare"
val moveStr = s"$fromSquare$clickedSquare"
engine.processUserInput(moveStr)
selectedSquare = None
def updateBoard(board: Board, turn: Color): Unit =
currentBoard = board
currentTurn = turn
selectedSquare = None
// Update all squares
for
rank <- 0 until 8
file <- 0 until 8
do
squareViews.get((rank, file)).foreach { stackPane =>
val isWhite = (rank + file) % 2 == 0
val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle {
width = squareSize
height = squareSize
@@ -218,16 +216,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcWidth = 8
arcHeight = 8
}
val square = Square(File.values(file), Rank.values(rank))
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square)
val children = pieceOption match
case Some(piece) =>
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
case None =>
Seq(bgRect)
stackPane.children = children
}
@@ -246,20 +244,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcWidth = 8
arcHeight = 8
}
val square = Square(File.values(file), Rank.values(rank))
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.pieceAt(square)
stackPane.children = pieceOption match
case Some(piece) =>
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
case None =>
Seq(bgRect)
}
def showMessage(msg: String): Unit =
messageLabel.text = msg
def showPromotionDialog(from: Square, to: Square): Unit =
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
@@ -268,14 +266,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
headerText = "Choose promotion piece"
contentText = "Promote to:"
}
val result = dialog.showAndWait()
result match
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
private def doFenExport(): Unit =
doExport(FenExporter, "FEN")
@@ -294,7 +292,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
showCopyDialog(s"$formatName Export", exported)
}
private def doImport(importer: GameContextImport, formatName: String): Unit = {
private def doImport(importer: GameContextImport, formatName: String): Unit =
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
importer.importGameContext(input) match
case Right(gameContext) =>
@@ -303,7 +301,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case Left(err) =>
showMessage(s"⚠️ $formatName Error: $err")
}
}
private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content)
@@ -327,7 +324,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.getDialogPane.setContent(area)
dialog.getDialogPane.getButtonTypes.addAll(
javafx.scene.control.ButtonType.OK,
javafx.scene.control.ButtonType.CANCEL
javafx.scene.control.ButtonType.CANCEL,
)
dialog.setResultConverter { bt =>
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
@@ -335,4 +332,3 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.initOwner(stage.delegate)
val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
@@ -1,63 +1,58 @@
package de.nowchess.ui.gui
import javafx.application.{Application => JFXApplication, Platform => JFXPlatform}
import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
import javafx.stage.Stage as JFXStage
import scalafx.application.Platform
import scalafx.scene.Scene
import scalafx.stage.Stage
import de.nowchess.chess.engine.GameEngine
/** ScalaFX GUI Application for Chess.
* This is launched from Main alongside the TUI.
* Both subscribe to the same GameEngine via Observer pattern.
*/
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
* GameEngine via Observer pattern.
*/
class ChessGUIApp extends JFXApplication:
override def start(primaryStage: JFXStage): Unit =
val engine = ChessGUILauncher.getEngine
val stage = new Stage(primaryStage)
val stage = new Stage(primaryStage)
stage.title = "Chess"
stage.width = 700
stage.height = 1000
stage.resizable = false
val boardView = new ChessBoardView(stage, engine)
val boardView = new ChessBoardView(stage, engine)
val guiObserver = new GUIObserver(boardView)
// Subscribe GUI observer to engine
engine.subscribe(guiObserver)
stage.scene = new Scene {
root = boardView
// Load CSS if available
try {
val cssUrl = getClass.getResource("/styles.css")
if cssUrl != null then
stylesheets.add(cssUrl.toExternalForm)
if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
} catch {
case _: Exception => // CSS is optional
}
}
stage.onCloseRequest = _ => {
stage.onCloseRequest = _ =>
// Unsubscribe when window closes
engine.unsubscribe(guiObserver)
}
stage.show()
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
object ChessGUILauncher:
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
def getEngine: GameEngine = engine
def launch(eng: GameEngine): Unit =
engine = eng
val guiThread = new Thread(() => {
JFXApplication.launch(classOf[ChessGUIApp])
})
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
guiThread.setDaemon(false)
guiThread.setName("ScalaFX-GUI-Thread")
guiThread.start()
@@ -3,13 +3,12 @@ package de.nowchess.ui.gui
import scalafx.application.Platform
import scalafx.scene.control.Alert
import scalafx.scene.control.Alert.AlertType
import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.chess.observer.{GameEvent, Observer, *}
import de.nowchess.api.board.Board
/** GUI Observer that implements the Observer pattern.
* Receives game events from GameEngine and updates the ScalaFX UI.
* All UI updates must be done on the JavaFX Application Thread.
*/
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
* All UI updates must be done on the JavaFX Application Thread.
*/
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
override def onGameEvent(event: GameEvent): Unit =
@@ -60,8 +59,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
boardView.updateBoard(e.context.board, e.context.turn)
if e.capturedPiece.isDefined then
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
else
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: PgnLoadedEvent =>
@@ -1,38 +1,36 @@
package de.nowchess.ui.gui
import scalafx.scene.image.{Image, ImageView}
import de.nowchess.api.board.{Piece, PieceType, Color}
import de.nowchess.api.board.{Color, Piece, PieceType}
/** Utility object for loading chess piece sprites. */
object PieceSprites:
private val spriteCache = scala.collection.mutable.Map[String, Image]()
/** Load a piece sprite image from resources.
* Sprites are cached for performance.
*/
/** Load a piece sprite image from resources. Sprites are cached for performance.
*/
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
new ImageView(image) {
fitWidth = size
fitHeight = size
preserveRatio = true
smooth = true
}
private def loadImage(key: String): Image =
val path = s"/sprites/pieces/$key.png"
val path = s"/sprites/pieces/$key.png"
val stream = getClass.getResourceAsStream(path)
if stream == null then
throw new RuntimeException(s"Could not load sprite: $path")
if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
new Image(stream)
/** Get square colors for the board using theme. */
object SquareColors:
val White = "#F3C8A0" // Warm light beige
val Black = "#BA6D4B" // Warm terracotta
val Selected = "#C19EF5" // Purple highlight
val White = "#F3C8A0" // Warm light beige
val Black = "#BA6D4B" // Warm terracotta
val Selected = "#C19EF5" // Purple highlight
val ValidMove = "#E1EAA9" // Light yellow-green
val Border = "#5A2C28" // Dark brown border
val Border = "#5A2C28" // Dark brown border
@@ -6,12 +6,11 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer
/** Terminal UI that implements Observer pattern.
* Subscribes to GameEngine and receives state change events.
* Handles all I/O and user interaction in the terminal.
*/
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
* I/O and user interaction in the terminal.
*/
class TerminalUI(engine: GameEngine) extends Observer:
private var running = true
private var running = true
private var awaitingPromotion = false
/** Called by GameEngine whenever a game event occurs. */
@@ -5,24 +5,26 @@ import de.nowchess.api.board.*
object Renderer:
private val AnsiReset = "\u001b[0m"
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
private val AnsiWhitePiece = "\u001b[97m" // bright white text
private val AnsiBlackPiece = "\u001b[30m" // black text
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
private val AnsiWhitePiece = "\u001b[97m" // bright white text
private val AnsiBlackPiece = "\u001b[30m" // black text
def render(board: Board): String =
val rows = (0 until 8).reverse.map { rank =>
val cells = (0 until 8).map { file =>
val sq = Square(File.values(file), Rank.values(rank))
val isLightSq = (file + rank) % 2 != 0
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
board.pieceAt(sq) match
case Some(piece) =>
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
case None =>
s"$bgColor $AnsiReset"
}.mkString
s"${rank + 1} $cells ${rank + 1}"
}.mkString("\n")
val rows = (0 until 8).reverse
.map { rank =>
val cells = (0 until 8).map { file =>
val sq = Square(File.values(file), Rank.values(rank))
val isLightSq = (file + rank) % 2 != 0
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
board.pieceAt(sq) match
case Some(piece) =>
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
case None =>
s"$bgColor $AnsiReset"
}.mkString
s"${rank + 1} $cells ${rank + 1}"
}
.mkString("\n")
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
@@ -19,16 +19,16 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
(Piece(Color.Black, PieceType.Rook), "\u265C"),
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
(Piece(Color.Black, PieceType.Knight), "\u265E"),
(Piece(Color.Black, PieceType.Pawn), "\u265F")
(Piece(Color.Black, PieceType.Pawn), "\u265F"),
)
pieces.foreach { (piece, expected) =>
piece.unicode shouldBe expected
}
test("render outputs coordinates ranks ansi escapes and piece glyphs"):
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
val rendered = Renderer.render(Board(Map.empty))
val lines = rendered.trim.split("\\n").toList.map(_.trim)
val lines = rendered.trim.split("\\n").toList.map(_.trim)
lines.head shouldBe "a b c d e f g h"
lines.last shouldBe "a b c d e f g h"
@@ -38,9 +38,7 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
Renderer.render(board) should include("\u001b[")
test("render applies black piece color for black pieces"):
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
val rendered = Renderer.render(board)
rendered should include("\u265A") // Black king unicode
rendered should include("\u001b[30m") // ANSI black text color
rendered should include("\u265A") // Black king unicode
rendered should include("\u001b[30m") // ANSI black text color