refactor: NCS-22 NCS-23 reworked modules and tests #17

Merged
Janis merged 42 commits from refactor/NCS-22 into main 2026-04-06 09:07:40 +02:00
12 changed files with 84 additions and 103 deletions
Showing only changes of commit cd6cce163d - Show all commits
@@ -1,13 +1,13 @@
package de.nowchess.chess.logic
package de.nowchess.api.game
import de.nowchess.api.board.{PieceType, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.move.{Move, PromotionPiece}
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
case class HistoryMove(
from: Square,
to: Square,
castleSide: Option[CastleSide],
castleSide: Option[String] = None,
promotionPiece: Option[PromotionPiece] = None,
pieceType: PieceType = PieceType.Pawn,
isCapture: Boolean = false
@@ -17,6 +17,8 @@ case class HistoryMove(
*
* @param moves moves played so far, oldest first
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
*
* Deprecated: Use GameContext instead. This exists for compatibility during migration.
*/
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
@@ -36,7 +38,7 @@ case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int
def addMove(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None,
castleSide: Option[String] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false,
@@ -1,7 +1,7 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Board, Color, Piece}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.api.game.GameHistory
/** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions.
@@ -2,6 +2,7 @@ package de.nowchess.chess.controller
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.GameHistory
import de.nowchess.chess.logic.*
// ---------------------------------------------------------------------------
@@ -83,6 +84,7 @@ object GameController:
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
val castleOptStr = castleOpt.map(_.toString) // Convert CastleSide to String
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
@@ -95,7 +97,7 @@ object GameController:
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, pieceType = pieceType)
val newHistory = history.addMove(from, to, castleOptStr, wasPawnMove = wasPawnMove, wasCapture = wasCapture, pieceType = pieceType)
toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
@@ -1,8 +1,8 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
import de.nowchess.api.board.{Board, Color, Piece, Square, CastlingRights}
import de.nowchess.api.move.{PromotionPiece, Move}
import de.nowchess.api.game.{GameHistory, GameContext}
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
@@ -43,6 +43,16 @@ class GameEngine(
def history: GameHistory = synchronized { currentHistory }
def turn: Color = synchronized { currentTurn }
/** 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. */
def canUndo: Boolean = synchronized { invoker.canUndo }
@@ -74,18 +84,16 @@ class GameEngine(
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear()
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
notifyObservers(DrawClaimedEvent(currentContext))
else
notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn,
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
case "" =>
val event = InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
currentContext,
"Please enter a valid move or command."
)
notifyObservers(event)
@@ -94,7 +102,7 @@ class GameEngine(
Parser.parseMove(moveInput) match
case None =>
notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn,
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
))
case Some((from, to)) =>
@@ -119,16 +127,16 @@ class GameEngine(
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
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(currentBoard, currentHistory, currentTurn))
notifyObservers(CheckDetectedEvent(currentContext))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
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)))
@@ -136,7 +144,7 @@ class GameEngine(
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
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)))
@@ -144,11 +152,11 @@ class GameEngine(
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
notifyObservers(StalemateEvent(currentContext))
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
notifyObservers(PromotionRequiredEvent(currentContext, promFrom, promTo))
/** Undo the last move. */
def undo(): Unit = synchronized {
@@ -166,7 +174,7 @@ class GameEngine(
def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match
case None =>
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
case Some(pending) =>
pendingPromotion = None
val cmd = MoveCommand(
@@ -191,7 +199,7 @@ class GameEngine(
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
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)))
@@ -199,7 +207,7 @@ class GameEngine(
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
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)))
@@ -207,10 +215,10 @@ class GameEngine(
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
notifyObservers(StalemateEvent(currentContext))
case _ =>
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
}
/** Validate and load a PGN string.
@@ -253,7 +261,7 @@ class GameEngine(
currentTurn = initialTurnBeforeLoad
Left(err)
case None =>
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
notifyObservers(PgnLoadedEvent(currentContext))
Right(())
}
@@ -264,7 +272,7 @@ class GameEngine(
currentTurn = turn
pendingPromotion = None
invoker.clear()
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
notifyObservers(BoardResetEvent(currentContext))
}
/** Reset the board to initial position. */
@@ -274,9 +282,7 @@ class GameEngine(
currentTurn = Color.White
invoker.clear()
notifyObservers(BoardResetEvent(
currentBoard,
currentHistory,
currentTurn
currentContext
))
}
@@ -292,9 +298,9 @@ class GameEngine(
moveCmd.previousHistory.foreach(currentHistory = _)
moveCmd.previousTurn.foreach(currentTurn = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentBoard, currentHistory, currentTurn, notation))
notifyObservers(MoveUndoneEvent(currentContext, notation))
else
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit =
if invoker.canRedo then
@@ -306,9 +312,9 @@ class GameEngine(
invoker.redo()
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))
notifyObservers(MoveRedoneEvent(currentContext, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc))
else
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
currentBoard = newBoard
@@ -318,9 +324,7 @@ class GameEngine(
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(
currentBoard,
currentHistory,
newTurn,
currentContext,
fromSq,
toSq,
capturedDesc
@@ -330,23 +334,17 @@ class GameEngine(
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
case MoveResult.NoPiece =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
currentContext,
"No piece on that square."
))
case MoveResult.WrongColor =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
currentContext,
"That is not your piece."
))
case MoveResult.IllegalMove =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
currentContext,
"Illegal move."
))
@@ -1,7 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.CastlingRights
import de.nowchess.api.game.{CastlingRights, GameHistory}
/** Derives castling rights from move history. */
object CastlingRightsCalculator:
@@ -1,6 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.api.game.GameHistory
object EnPassantCalculator:
@@ -1,7 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.GameHistory
import de.nowchess.api.game.GameHistory
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
@@ -1,7 +1,8 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{CastleSide, GameHistory}
import de.nowchess.chess.logic.CastleSide
import de.nowchess.api.game.GameHistory
object MoveValidator:
@@ -2,7 +2,8 @@ package de.nowchess.chess.notation
import de.nowchess.api.board.{PieceType, *}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
import de.nowchess.api.game.{GameHistory, HistoryMove}
import de.nowchess.chess.logic.CastleSide
object PgnExporter:
@@ -32,9 +33,9 @@ object PgnExporter:
/** 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 =>
case Some("Kingside") => "O-O"
case Some("Queenside") => "O-O-O"
case Some(_) | None =>
val dest = move.to.toString
val capStr = if move.isCapture then "x" else ""
val promSuffix = move.promotionPiece match
@@ -2,7 +2,8 @@ package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
import de.nowchess.api.game.{GameHistory, HistoryMove}
import de.nowchess.chess.logic.{CastleSide, GameRules, MoveValidator, withCastle}
/** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame(
@@ -63,8 +64,9 @@ object PgnParser:
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
move.castleSide match
case Some(side) => board.withCastle(color, side)
case None =>
case Some("Kingside") => board.withCastle(color, CastleSide.Kingside)
case Some("Queenside") => board.withCastle(color, CastleSide.Queenside)
case _ =>
val (boardAfterMove, _) = board.withMove(move.from, move.to)
move.promotionPiece match
case Some(pp) =>
@@ -89,11 +91,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), pieceType = PieceType.King))
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some("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), pieceType = PieceType.King))
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some("Queenside"), pieceType = PieceType.King))
case _ =>
parseRegularMove(notation, board, history, color)
@@ -212,12 +214,12 @@ object PgnParser:
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)
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(CastleSide.Queenside), pieceType = PieceType.King)
HistoryMove(Square(File.E, rank), dest, Some("Queenside"), pieceType = PieceType.King)
)
case _ =>
strictParseRegularMove(notation, board, history, color)
@@ -1,21 +1,17 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Board, Color, Square}
import de.nowchess.chess.logic.GameHistory
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.
*/
sealed trait GameEvent:
def board: Board
def history: GameHistory
def turn: Color
def context: GameContext
/** Fired when a move is successfully executed. */
case class MoveExecutedEvent(
board: Board,
history: GameHistory,
turn: Color,
context: GameContext,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
@@ -23,77 +19,57 @@ case class MoveExecutedEvent(
/** Fired when the current player is in check. */
case class CheckDetectedEvent(
board: Board,
history: GameHistory,
turn: Color
context: GameContext
) extends GameEvent
/** Fired when the game reaches checkmate. */
case class CheckmateEvent(
board: Board,
history: GameHistory,
turn: Color,
context: GameContext,
winner: Color
) extends GameEvent
/** Fired when the game reaches stalemate. */
case class StalemateEvent(
board: Board,
history: GameHistory,
turn: Color
context: GameContext
) extends GameEvent
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
board: Board,
history: GameHistory,
turn: Color,
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(
board: Board,
history: GameHistory,
turn: Color,
context: GameContext,
from: Square,
to: Square
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
board: Board,
history: GameHistory,
turn: Color
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(
board: Board,
history: GameHistory,
turn: Color
context: GameContext
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
board: Board,
history: GameHistory,
turn: Color
context: GameContext
) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent(
board: Board,
history: GameHistory,
turn: Color,
context: GameContext,
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,
context: GameContext,
pgnNotation: String,
fromSquare: String,
toSquare: String,
@@ -102,9 +78,7 @@ case class MoveRedoneEvent(
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent(
board: Board,
history: GameHistory,
turn: Color
context: GameContext
) extends GameEvent
/** Observer trait: implement to receive game state updates. */
+1 -1
View File
@@ -1,2 +1,2 @@
rootProject.name = "NowChessSystems"
include("modules:core", "modules:api", "modules:ui")
include("modules:core", "modules:api", "modules:ui", "modules:rule")