refactor: migrate GameController to GameContext (signatures only)
Build & Test (NowChessSystems) TeamCity build finished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-24 13:04:57 +01:00
parent c9a59d3ad1
commit 62e180c6d9
3 changed files with 66 additions and 62 deletions
@@ -1,10 +1,11 @@
package de.nowchess.chess package de.nowchess.chess
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameContext
object Main { object Main {
def main(args: Array[String]): Unit = def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
} }
@@ -11,15 +11,15 @@ import de.nowchess.chess.view.Renderer
sealed trait MoveResult sealed trait MoveResult
object MoveResult: object MoveResult:
case object Quit extends MoveResult case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult case object NoPiece extends MoveResult
case object WrongColor extends MoveResult case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult case object Stalemate extends MoveResult
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Controller // Controller
@@ -27,10 +27,10 @@ object MoveResult:
object GameController: object GameController:
/** Pure function: interprets one raw input line against the current board state. /** 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. * Has no I/O side effects all output must be handled by the caller.
*/ */
def processMove(board: Board, turn: Color, raw: String): MoveResult = def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult =
raw.trim match raw.trim match
case "quit" | "q" => case "quit" | "q" =>
MoveResult.Quit MoveResult.Quit
@@ -39,61 +39,62 @@ object GameController:
case None => case None =>
MoveResult.InvalidFormat(trimmed) MoveResult.InvalidFormat(trimmed)
case Some((from, to)) => case Some((from, to)) =>
board.pieceAt(from) match ctx.board.pieceAt(from) match
case None => case None =>
MoveResult.NoPiece MoveResult.NoPiece
case Some(piece) if piece.color != turn => case Some(piece) if piece.color != turn =>
MoveResult.WrongColor MoveResult.WrongColor
case Some(_) => case Some(_) =>
if !MoveValidator.isLegal(board, from, to) then if !MoveValidator.isLegal(ctx, from, to) then
MoveResult.IllegalMove MoveResult.IllegalMove
else else
val (newBoard, captured) = board.withMove(from, to) val (newBoard, captured) = ctx.board.withMove(from, to)
GameRules.gameStatus(GameContext(newBoard), turn.opposite) match val newCtx = ctx.copy(board = newBoard)
case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite) GameRules.gameStatus(newCtx, turn.opposite) match
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite) case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate case PositionStatus.Drawn => MoveResult.Stalemate
/** Thin I/O shell: renders the board, reads a line, delegates to processMove, /** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends. * prints the outcome, and recurses until the game ends.
*/ */
def gameLoop(board: Board, turn: Color): Unit = def gameLoop(ctx: GameContext, turn: Color): Unit =
println() println()
print(Renderer.render(board)) print(Renderer.render(ctx.board))
println(s"${turn.label}'s turn. Enter move: ") println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, turn, input) match processMove(ctx, turn, input) match
case MoveResult.Quit => case MoveResult.Quit =>
println("Game over. Goodbye!") println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) => case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, turn) gameLoop(ctx, turn)
case MoveResult.NoPiece => case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, turn) gameLoop(ctx, turn)
case MoveResult.WrongColor => case MoveResult.WrongColor =>
println(s"That is not your piece.") println(s"That is not your piece.")
gameLoop(board, turn) gameLoop(ctx, turn)
case MoveResult.IllegalMove => case MoveResult.IllegalMove =>
println(s"Illegal move.") println(s"Illegal move.")
gameLoop(board, turn) gameLoop(ctx, turn)
case MoveResult.Moved(newBoard, captured, newTurn) => case MoveResult.Moved(newCtx, captured, newTurn) =>
val prevTurn = newTurn.opposite val prevTurn = newTurn.opposite
captured.foreach: cap => captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newTurn) gameLoop(newCtx, newTurn)
case MoveResult.MovedInCheck(newBoard, captured, newTurn) => case MoveResult.MovedInCheck(newCtx, captured, newTurn) =>
val prevTurn = newTurn.opposite val prevTurn = newTurn.opposite
captured.foreach: cap => captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!") println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newTurn) gameLoop(newCtx, newTurn)
case MoveResult.Checkmate(winner) => case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.") println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, Color.White) gameLoop(GameContext.initial, Color.White)
case MoveResult.Stalemate => case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.") println("Stalemate! The game is a draw.")
gameLoop(Board.initial, Color.White) gameLoop(GameContext.initial, Color.White)
@@ -1,6 +1,8 @@
package de.nowchess.chess.controller package de.nowchess.chess.controller
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{GameContext, CastleSide}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -9,7 +11,7 @@ import java.io.ByteArrayInputStream
class GameControllerTest extends AnyFunSuite with Matchers: class GameControllerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
private val initial = Board.initial private val initial = GameContext.initial
// ──── processMove ──────────────────────────────────────────────────── // ──── processMove ────────────────────────────────────────────────────
@@ -39,24 +41,24 @@ class GameControllerTest extends AnyFunSuite with Matchers:
test("processMove: legal pawn move returns Moved with updated board and flipped turn"): test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
GameController.processMove(initial, Color.White, "e2e4") match GameController.processMove(initial, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, captured, newTurn) => case MoveResult.Moved(newCtx, captured, newTurn) =>
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) newCtx.board.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None newCtx.board.pieceAt(sq(File.E, Rank.R2)) shouldBe None
captured shouldBe None captured shouldBe None
newTurn shouldBe Color.Black newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
test("processMove: legal capture returns Moved with the captured piece"): test("processMove: legal capture returns Moved with the captured piece"):
val captureBoard = Board(Map( val captureCtx = GameContext(Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn, sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.BlackKing, sq(File.H, Rank.R1) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.WhiteKing sq(File.H, Rank.R8) -> Piece.WhiteKing
)) )))
GameController.processMove(captureBoard, Color.White, "e5d6") match GameController.processMove(captureCtx, Color.White, "e5d6") match
case MoveResult.Moved(newBoard, captured, newTurn) => case MoveResult.Moved(newCtx, captured, newTurn) =>
captured shouldBe Some(Piece.BlackPawn) captured shouldBe Some(Piece.BlackPawn)
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) newCtx.board.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
newTurn shouldBe Color.Black newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other") case other => fail(s"Expected Moved, got $other")
@@ -68,33 +70,33 @@ class GameControllerTest extends AnyFunSuite with Matchers:
test("gameLoop: 'quit' exits cleanly without exception"): test("gameLoop: 'quit' exits cleanly without exception"):
withInput("quit\n"): withInput("quit\n"):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: EOF (null readLine) exits via quit fallback"): test("gameLoop: EOF (null readLine) exits via quit fallback"):
withInput(""): withInput(""):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: invalid format prints message and recurses until quit"): test("gameLoop: invalid format prints message and recurses until quit"):
withInput("badmove\nquit\n"): withInput("badmove\nquit\n"):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: NoPiece prints message and recurses until quit"): test("gameLoop: NoPiece prints message and recurses until quit"):
// E3 is empty in the initial position // E3 is empty in the initial position
withInput("e3e4\nquit\n"): withInput("e3e4\nquit\n"):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: WrongColor prints message and recurses until quit"): test("gameLoop: WrongColor prints message and recurses until quit"):
// E7 has a Black pawn; it is White's turn // E7 has a Black pawn; it is White's turn
withInput("e7e6\nquit\n"): withInput("e7e6\nquit\n"):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: IllegalMove prints message and recurses until quit"): test("gameLoop: IllegalMove prints message and recurses until quit"):
withInput("e2e5\nquit\n"): withInput("e2e5\nquit\n"):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: legal non-capture move recurses with new board then quits"): test("gameLoop: legal non-capture move recurses with new board then quits"):
withInput("e2e4\nquit\n"): withInput("e2e4\nquit\n"):
GameController.gameLoop(Board.initial, Color.White) GameController.gameLoop(GameContext.initial, Color.White)
test("gameLoop: capture move prints capture message then recurses and quits"): test("gameLoop: capture move prints capture message then recurses and quits"):
val captureBoard = Board(Map( val captureBoard = Board(Map(
@@ -104,7 +106,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.WhiteKing sq(File.H, Rank.R8) -> Piece.WhiteKing
)) ))
withInput("e5d6\nquit\n"): withInput("e5d6\nquit\n"):
GameController.gameLoop(captureBoard, Color.White) GameController.gameLoop(GameContext(captureBoard), Color.White)
// ──── helpers ──────────────────────────────────────────────────────── // ──── helpers ────────────────────────────────────────────────────────
@@ -118,12 +120,12 @@ class GameControllerTest extends AnyFunSuite with Matchers:
test("processMove: legal move that delivers check returns MovedInCheck"): test("processMove: legal move that delivers check returns MovedInCheck"):
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check // White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated // Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
val b = Board(Map( val ctx = GameContext(Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing, sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
)) )))
GameController.processMove(b, Color.White, "a1a8") match GameController.processMove(ctx, Color.White, "a1a8") match
case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black
case other => fail(s"Expected MovedInCheck, got $other") case other => fail(s"Expected MovedInCheck, got $other")
@@ -131,24 +133,24 @@ class GameControllerTest extends AnyFunSuite with Matchers:
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8) // White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8)
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position) // After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position)
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6 // Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
val b = Board(Map( val ctx = GameContext(Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen, sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
)) )))
GameController.processMove(b, Color.White, "a1h8") match GameController.processMove(ctx, Color.White, "a1h8") match
case MoveResult.Checkmate(winner) => winner shouldBe Color.White case MoveResult.Checkmate(winner) => winner shouldBe Color.White
case other => fail(s"Expected Checkmate(White), got $other") case other => fail(s"Expected Checkmate(White), got $other")
test("processMove: legal move that results in stalemate returns Stalemate"): test("processMove: legal move that results in stalemate returns Stalemate"):
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6 // White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position) // After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position)
val b = Board(Map( val ctx = GameContext(Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen, sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
)) )))
GameController.processMove(b, Color.White, "b1b6") match GameController.processMove(ctx, Color.White, "b1b6") match
case MoveResult.Stalemate => succeed case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other") case other => fail(s"Expected Stalemate, got $other")
@@ -163,7 +165,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("a1h8\nquit\n"): withInput("a1h8\nquit\n"):
GameController.gameLoop(b, Color.White) GameController.gameLoop(GameContext(b), Color.White)
output should include("Checkmate! White wins.") output should include("Checkmate! White wins.")
test("gameLoop: stalemate prints draw message and resets to new game"): test("gameLoop: stalemate prints draw message and resets to new game"):
@@ -174,7 +176,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("b1b6\nquit\n"): withInput("b1b6\nquit\n"):
GameController.gameLoop(b, Color.White) GameController.gameLoop(GameContext(b), Color.White)
output should include("Stalemate! The game is a draw.") output should include("Stalemate! The game is a draw.")
test("gameLoop: MovedInCheck without capture prints check message"): test("gameLoop: MovedInCheck without capture prints check message"):
@@ -185,7 +187,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("a1a8\nquit\n"): withInput("a1a8\nquit\n"):
GameController.gameLoop(b, Color.White) GameController.gameLoop(GameContext(b), Color.White)
output should include("Black is in check!") output should include("Black is in check!")
test("gameLoop: MovedInCheck with capture prints both capture and check message"): test("gameLoop: MovedInCheck with capture prints both capture and check message"):
@@ -198,6 +200,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
)) ))
val output = captureOutput: val output = captureOutput:
withInput("a1a8\nquit\n"): withInput("a1a8\nquit\n"):
GameController.gameLoop(b, Color.White) GameController.gameLoop(GameContext(b), Color.White)
output should include("captures") output should include("captures")
output should include("Black is in check!") output should include("Black is in check!")