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

Reviewed-on: #27
Reviewed-by: Leon Hermann <lq@blackhole.local>
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 #27.
This commit is contained in:
2026-04-12 20:58:39 +02:00
committed by Janis
parent 3cb3160731
commit fd4e67d4f7
79 changed files with 1671 additions and 1457 deletions
@@ -1,11 +1,11 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Piece}
import de.nowchess.api.board.{Piece, Square}
import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions.
*/
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
* transitions.
*/
trait Command:
/** Execute the command and return true if successful, false otherwise. */
def execute(): Boolean
@@ -16,15 +16,14 @@ trait Command:
/** A human-readable description of this command. */
def description: String
/** Command to move a piece from one square to another.
* Stores the move result so undo can restore previous state.
*/
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
*/
case class MoveCommand(
from: Square,
to: Square,
moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None,
notation: String = ""
from: Square,
to: Square,
moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None,
notation: String = "",
) extends Command:
override def execute(): Boolean =
@@ -39,18 +38,18 @@ case class MoveCommand(
sealed trait MoveResult
object MoveResult:
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult
case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult
/** Command to quit the game. */
case class QuitCommand() extends Command:
override def execute(): Boolean = true
override def undo(): Boolean = false
override def execute(): Boolean = true
override def undo(): Boolean = false
override def description: String = "Quit game"
/** Command to reset the board to initial position. */
case class ResetCommand(
previousContext: Option[GameContext] = None
previousContext: Option[GameContext] = None,
) extends Command:
override def execute(): Boolean = true
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */
class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentIndex = -1
/** Execute a command and add it to history.
* Discards any redo history if not at the end of the stack.
*/
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
*/
def execute(command: Command): Boolean = synchronized {
if command.execute() then
// Remove any commands after current index (redo stack is discarded)
while currentIndex < executedCommands.size - 1 do
executedCommands.remove(executedCommands.size - 1)
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
executedCommands += command
currentIndex += 1
true
else
false
else false
}
/** Undo the last executed command if possible. */
@@ -27,10 +25,8 @@ class CommandInvoker:
if command.undo() then
currentIndex -= 1
true
else
false
else
false
else false
else false
}
/** Redo the next command in history if available. */
@@ -40,10 +36,8 @@ class CommandInvoker:
if command.execute() then
currentIndex += 1
true
else
false
else
false
else false
else false
}
/** Get the history of all executed commands. */
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
object Parser:
/** Parses coordinate notation such as "e2e4" or "g1f3".
* Returns None for any input that does not match the expected format.
*/
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
* format.
*/
def parseMove(input: String): Option[(Square, Square)] =
val trimmed = input.trim.toLowerCase
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
for
from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4))
yield (from, to)
Option
.when(trimmed.length == 4)(trimmed)
.flatMap: s =>
for
from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4))
yield (from, to)
private def parseSquare(s: String): Option[Square] =
Option.when(s.length == 2)(s).flatMap: sq =>
val fileIdx = sq(0) - 'a'
val rankIdx = sq(1) - '1'
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx))
)
Option
.when(s.length == 2)(s)
.flatMap: sq =>
val fileIdx = sq(0) - 'a'
val rankIdx = sq(1) - '1'
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx)),
)
@@ -6,45 +6,46 @@ import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextImport, GameContextExport}
import de.nowchess.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
/** Pure game engine that manages game state and notifies observers of state changes.
* All rule queries delegate to the injected RuleSet.
* All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules,
) extends Observable:
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
@SuppressWarnings(Array("DisableSyntax.var"))
private var pendingPromotion: Option[PendingPromotion] = None
/** 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
def board: Board = synchronized { currentContext.board }
def turn: Color = synchronized { currentContext.turn }
def context: GameContext = synchronized { currentContext }
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext)
/** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo }
def canUndo: Boolean = synchronized(invoker.canUndo)
/** Check if redo is available. */
def canRedo: Boolean = synchronized { invoker.canRedo }
def canRedo: Boolean = synchronized(invoker.canRedo)
/** Get the command history for inspection (testing/debugging). */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
/** Process a raw move input string and update game state if valid.
* Notifies all observers of the outcome via GameEvent.
*/
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
* GameEvent.
*/
def processUserInput(rawInput: String): Unit = synchronized {
val trimmed = rawInput.trim.toLowerCase
trimmed match
@@ -62,10 +63,12 @@ class GameEngine(
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
else
notifyObservers(InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
notifyObservers(
InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered.",
),
)
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
@@ -73,10 +76,12 @@ class GameEngine(
case moveInput =>
Parser.parseMove(moveInput) match
case None =>
notifyObservers(InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
))
notifyObservers(
InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
),
)
case Some((from, to)) =>
handleParsedMove(from, to)
}
@@ -108,9 +113,8 @@ class GameEngine(
to.rank.ordinal == promoRank
}
/** Apply a player's promotion piece choice.
* Must only be called when isPendingPromotion is true.
*/
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
*/
def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match
case None =>
@@ -120,23 +124,19 @@ class GameEngine(
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext)(pending.from)
if legal.contains(move) then
executeMove(move)
else
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
if legal.contains(move) then executeMove(move)
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
}
/** Undo the last move. */
def undo(): Unit = synchronized { performUndo() }
def undo(): Unit = synchronized(performUndo())
/** Redo the last undone move. */
def redo(): Unit = synchronized { performRedo() }
def redo(): Unit = synchronized(performRedo())
/** Load a game using the provided importer.
* If the imported context has moves, they are replayed through the command system.
* Otherwise, the position is set directly.
* Notifies observers with PgnLoadedEvent on success.
*/
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
*/
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
importer.importGameContext(input) match
case Left(err) => Left(err)
@@ -155,29 +155,24 @@ class GameEngine(
if ctx.moves.isEmpty then
currentContext = ctx
Right(())
else
replayMoves(ctx.moves, savedContext)
else replayMoves(ctx.moves, savedContext)
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
var error: Option[String] = None
moves.foreach: move =>
if error.isEmpty then
handleParsedMove(move.from, move.to)
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
acc.flatMap(_ => applyReplayMove(move))
}
result.left.foreach(_ => currentContext = savedContext)
result
move.moveType match {
case MoveType.Promotion(pp) =>
if pendingPromotion.isDefined then
completePromotion(pp)
else
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
}
error match
case Some(err) =>
currentContext = savedContext
Left(err)
case None =>
private def applyReplayMove(move: Move): Either[String, Unit] =
handleParsedMove(move.from, move.to)
move.moveType match
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
completePromotion(pp)
Right(())
case MoveType.Promotion(_) =>
Left(s"Promotion required for move ${move.from}${move.to}")
case _ => Right(())
/** Export the current game context using the provided exporter. */
def exportGame(exporter: GameContextExport): String = synchronized {
@@ -203,25 +198,27 @@ class GameEngine(
private def executeMove(move: Move): Unit =
val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
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 = translateMoveToNotation(move, contextBefore.board)
notation = translateMoveToNotation(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}")
))
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
@@ -232,18 +229,16 @@ class GameEngine(
notifyObservers(StalemateEvent(currentContext))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isCheck(currentContext) then
notifyObservers(CheckDetectedEvent(currentContext))
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
private def enPassantNotation(move: Move): String =
@@ -295,8 +290,7 @@ class GameEngine(
moveCmd.previousContext.foreach(currentContext = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit =
if invoker.canRedo then
@@ -307,12 +301,13 @@ class GameEngine(
currentContext = nextCtx
invoker.redo()
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers(MoveRedoneEvent(
currentContext,
moveCmd.notation,
moveCmd.from.toString,
moveCmd.to.toString,
capturedDesc
))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
notifyObservers(
MoveRedoneEvent(
currentContext,
moveCmd.notation,
moveCmd.from.toString,
moveCmd.to.toString,
capturedDesc,
),
)
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
@@ -3,82 +3,81 @@ package de.nowchess.chess.observer
import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.game.GameContext
/** Base trait for all game state events.
* Events are immutable snapshots of game state changes.
*/
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
*/
sealed trait GameEvent:
def context: GameContext
/** Fired when a move is successfully executed. */
case class MoveExecutedEvent(
context: GameContext,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
context: GameContext,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String],
) extends GameEvent
/** Fired when the current player is in check. */
case class CheckDetectedEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when the game reaches checkmate. */
case class CheckmateEvent(
context: GameContext,
winner: Color
context: GameContext,
winner: Color,
) extends GameEvent
/** Fired when the game reaches stalemate. */
case class StalemateEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
context: GameContext,
reason: String
context: GameContext,
reason: String,
) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent(
context: GameContext,
from: Square,
to: Square
context: GameContext,
from: Square,
to: Square,
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent(
context: GameContext,
pgnNotation: String
context: GameContext,
pgnNotation: String,
) extends GameEvent
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
case class MoveRedoneEvent(
context: GameContext,
pgnNotation: String,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
context: GameContext,
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(
context: GameContext
context: GameContext,
) extends GameEvent
/** Observer trait: implement to receive game state updates. */