refactor: NCS-8 removed Context and replaced it with History (#6)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-03-28 18:08:55 +01:00
parent 4d800e88eb
commit f4c18d22d7
16 changed files with 460 additions and 582 deletions
@@ -1,11 +1,12 @@
package de.nowchess.chess
import de.nowchess.api.board.Board
import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameContext
import de.nowchess.chess.logic.GameHistory
object Main {
def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(GameContext.initial, Color.White)
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
}
@@ -2,8 +2,7 @@ package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus, CastleSide, withCastle}
import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
@@ -17,8 +16,8 @@ object MoveResult:
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
@@ -31,7 +30,7 @@ object GameController:
/** Pure function: interprets one raw input line against the current game context.
* Has no I/O side effects all output must be handled by the caller.
*/
def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult =
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
@@ -40,97 +39,67 @@ object GameController:
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
ctx.board.pieceAt(from) match
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(ctx, from, to) then
if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove
else
val castleOpt = if MoveValidator.isCastle(ctx.board, from, to)
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val (newBoard, captured) = castleOpt match
case Some(side) => (ctx.board.withCastle(turn, side), None)
case None => ctx.board.withMove(from, to)
val newCtx = applyRightsRevocation(
ctx.copy(board = newBoard), turn, from, to, castleOpt
)
GameRules.gameStatus(newCtx, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite)
case Some(side) => (board.withCastle(turn, side), None)
case None => board.withMove(from, to)
val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
private def applyRightsRevocation(
ctx: GameContext,
turn: Color,
from: Square,
to: Square,
castle: Option[CastleSide]
): GameContext =
// Step 1: Revoke all rights for a castling move (idempotent with step 2)
val ctx0 = castle.fold(ctx)(_ => ctx.withUpdatedRights(turn, CastlingRights.None))
// Step 2: Source-square revocation
val ctx1 = from match
case Square(File.E, Rank.R1) => ctx0.withUpdatedRights(Color.White, CastlingRights.None)
case Square(File.E, Rank.R8) => ctx0.withUpdatedRights(Color.Black, CastlingRights.None)
case Square(File.A, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(queenSide = false))
case Square(File.H, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(kingSide = false))
case Square(File.A, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(queenSide = false))
case Square(File.H, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(kingSide = false))
case _ => ctx0
// Step 3: Destination-square revocation (enemy captures a rook on its home square)
to match
case Square(File.A, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(queenSide = false))
case Square(File.H, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(kingSide = false))
case Square(File.A, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(queenSide = false))
case Square(File.H, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(kingSide = false))
case _ => ctx1
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(ctx: GameContext, turn: Color): Unit =
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
println()
print(Renderer.render(ctx.board))
print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(ctx, turn, input) match
processMove(board, history, turn, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(ctx, turn)
gameLoop(board, history, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(ctx, turn)
gameLoop(board, history, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(ctx, turn)
gameLoop(board, history, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(ctx, turn)
case MoveResult.Moved(newCtx, captured, newTurn) =>
gameLoop(board, history, turn)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newCtx, newTurn)
case MoveResult.MovedInCheck(newCtx, captured, newTurn) =>
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newCtx, newTurn)
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(GameContext.initial, Color.White)
gameLoop(Board.initial, GameHistory.empty, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(GameContext.initial, Color.White)
gameLoop(Board.initial, GameHistory.empty, Color.White)
@@ -0,0 +1,23 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum CastleSide:
case Kingside, Queenside
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingFrom = Square(File.E, rank)
val (kingTo, rookFrom, rookTo) = side match
case CastleSide.Kingside =>
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
case CastleSide.Queenside =>
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = b.pieceAt(kingFrom).get
val rook = b.pieceAt(rookFrom).get
b.removed(kingFrom).removed(rookFrom)
.updated(kingTo, king)
.updated(rookTo, rook)
@@ -0,0 +1,31 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.CastlingRights
/** Derives castling rights from move history. */
object CastlingRightsCalculator:
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
val (kingRow, kingsideRookFile, queensideRookFile) = color match
case Color.White => (Rank.R1, File.H, File.A)
case Color.Black => (Rank.R8, File.H, File.A)
// Check if king has moved
val kingHasMoved = history.moves.exists: move =>
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
if kingHasMoved then
CastlingRights.None
else
// Check if kingside rook has moved or was captured
val kingsideLost = history.moves.exists: move =>
move.from == Square(kingsideRookFile, kingRow) ||
move.to == Square(kingsideRookFile, kingRow)
// Check if queenside rook has moved or was captured
val queensideLost = history.moves.exists: move =>
move.from == Square(queensideRookFile, kingRow) ||
move.to == Square(queensideRookFile, kingRow)
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
@@ -1,47 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.CastlingRights
enum CastleSide:
case Kingside, Queenside
case class GameContext(
board: Board,
whiteCastling: CastlingRights,
blackCastling: CastlingRights
):
def castlingFor(color: Color): CastlingRights =
if color == Color.White then whiteCastling else blackCastling
def withUpdatedRights(color: Color, rights: CastlingRights): GameContext =
if color == Color.White then copy(whiteCastling = rights)
else copy(blackCastling = rights)
object GameContext:
/** Convenience constructor for test boards: no castling rights on either side. */
def apply(board: Board): GameContext =
GameContext(board, CastlingRights.None, CastlingRights.None)
val initial: GameContext =
GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both)
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board =
val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match
case (Color.White, CastleSide.Kingside) =>
(Square(File.E, Rank.R1), Square(File.G, Rank.R1),
Square(File.H, Rank.R1), Square(File.F, Rank.R1))
case (Color.White, CastleSide.Queenside) =>
(Square(File.E, Rank.R1), Square(File.C, Rank.R1),
Square(File.A, Rank.R1), Square(File.D, Rank.R1))
case (Color.Black, CastleSide.Kingside) =>
(Square(File.E, Rank.R8), Square(File.G, Rank.R8),
Square(File.H, Rank.R8), Square(File.F, Rank.R8))
case (Color.Black, CastleSide.Queenside) =>
(Square(File.E, Rank.R8), Square(File.C, Rank.R8),
Square(File.A, Rank.R8), Square(File.D, Rank.R8))
val king = Piece(color, PieceType.King)
val rook = Piece(color, PieceType.Rook)
Board(b.pieces.removed(kingFrom).removed(rookFrom)
.updated(kingTo, king).updated(rookTo, rook))
@@ -0,0 +1,24 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
/** 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]
)
/** Complete game history: ordered list of moves. */
case class GameHistory(moves: List[HistoryMove] = List.empty):
def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move)
def addMove(from: Square, to: Square): GameHistory =
addMove(HistoryMove(from, to, None))
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory =
addMove(HistoryMove(from, to, castleSide))
object GameHistory:
val empty: GameHistory = GameHistory()
@@ -1,7 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.GameContext
import de.nowchess.chess.logic.GameHistory
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
@@ -20,17 +20,17 @@ object GameRules:
}
/** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] =
ctx.board.pieces
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(ctx, from) // context-aware: includes castling
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
.filter { to =>
val newBoard =
if MoveValidator.isCastle(ctx.board, from, to) then
ctx.board.withCastle(color, MoveValidator.castleSide(from, to))
if MoveValidator.isCastle(board, from, to) then
board.withCastle(color, MoveValidator.castleSide(from, to))
else
ctx.board.withMove(from, to)._1
board.withMove(from, to)._1
!isInCheck(newBoard, color)
}
.map(to => from -> to)
@@ -38,9 +38,9 @@ object GameRules:
.toSet
/** Position status for the side whose turn it is (`color`). */
def gameStatus(ctx: GameContext, color: Color): PositionStatus =
val moves = legalMoves(ctx, color)
val inCheck = isInCheck(ctx.board, color)
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
val moves = legalMoves(board, history, color)
val inCheck = isInCheck(board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck
@@ -1,7 +1,7 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{GameContext, CastleSide}
import de.nowchess.chess.logic.{CastleSide, GameHistory}
object MoveValidator:
@@ -126,37 +126,37 @@ object MoveValidator:
def castleSide(from: Square, to: Square): CastleSide =
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
def castlingTargets(ctx: GameContext, color: Color): Set[Square] =
val rights = ctx.castlingFor(color)
def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank)
val enemy = color.opposite
if !ctx.board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(ctx.board, color) then Set.empty
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(board, color) then Set.empty
else
val kingsideSq = Option.when(
rights.kingSide &&
ctx.board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.F, rank), Square(File.G, rank)).forall(s => ctx.board.pieceAt(s).isEmpty) &&
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(ctx.board, s, enemy))
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
)(Square(File.G, rank))
val queensideSq = Option.when(
rights.queenSide &&
ctx.board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => ctx.board.pieceAt(s).isEmpty) &&
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(ctx.board, s, enemy))
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
)(Square(File.C, rank))
kingsideSq.toSet ++ queensideSq.toSet
def legalTargets(ctx: GameContext, from: Square): Set[Square] =
ctx.board.pieceAt(from) match
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color)
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case _ =>
legalTargets(ctx.board, from)
legalTargets(board, from)
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean =
legalTargets(ctx, from).contains(to)
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)