22 KiB
Chess Check / Checkmate / Stalemate Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add check detection, checkmate (win by opponent having no legal reply while in check), and stalemate (draw by opponent having no legal reply while not in check) to the chess game loop.
Architecture: A new GameRules object owns all check-aware logic; the existing MoveValidator keeps its geometric-only contract unchanged. GameController.processMove calls GameRules.gameStatus after each move and returns new MoveResult variants (MovedInCheck, Checkmate, Stalemate). Terminal states reset the board.
Tech Stack: Scala 3.5, ScalaTest (AnyFunSuite with Matchers), Gradle (:modules:core:test)
File Map
| File | Action | Responsibility |
|---|---|---|
modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala |
Create | isInCheck, legalMoves, gameStatus, PositionStatus enum |
modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala |
Create | Unit tests for all three GameRules methods |
modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala |
Modify | Add MovedInCheck/Checkmate/Stalemate to MoveResult; wire processMove and gameLoop |
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala |
Modify | Add processMove and gameLoop tests for the three new results |
Task 1: Create GameRules stub
Files:
-
Create:
modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala -
Step 1: Create the stub file
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
object GameRules:
/** True if `color`'s king is under attack on this board. */
def isInCheck(board: Board, color: Color): Boolean = false
/** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(board: Board, color: Color): Set[(Square, Square)] = Set.empty
/** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, color: Color): PositionStatus = PositionStatus.Normal
- Step 2: Verify the project compiles
./gradlew :modules:core:compileScala
Expected: BUILD SUCCESSFUL
- Step 3: Commit
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala
git commit -m "feat: add GameRules stub with PositionStatus enum"
Task 2: Write GameRulesTest (all tests must fail)
Files:
-
Create:
modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala -
Step 1: Create the test file
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameRulesTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
// ──── isInCheck ──────────────────────────────────────────────────────
test("isInCheck: king attacked by enemy rook on same rank"):
// White King E1, Black Rook A1 — rook slides along rank 1 to E1
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.BlackRook
)
GameRules.isInCheck(b, Color.White) shouldBe true
test("isInCheck: king not attacked"):
// Black Rook A3 does not cover E1
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R3) -> Piece.BlackRook
)
GameRules.isInCheck(b, Color.White) shouldBe false
test("isInCheck: no king on board returns false"):
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
GameRules.isInCheck(b, Color.White) shouldBe false
// ──── legalMoves ─────────────────────────────────────────────────────
test("legalMoves: move that exposes own king to rook is excluded"):
// White King E1, White Rook E4 (pinned on E-file), Black Rook E8
// Moving the White Rook off the E-file would expose the king
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook
)
val moves = GameRules.legalMoves(b, Color.White)
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
test("legalMoves: move that blocks check is included"):
// White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook
)
val moves = GameRules.legalMoves(b, Color.White)
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
// ──── gameStatus ──────────────────────────────────────────────────────
test("gameStatus: checkmate returns Mated"):
// White Qh8, Ka6; Black Ka8
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
val b = board(
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
)
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Mated
test("gameStatus: stalemate returns Drawn"):
// White Qb6, Kc6; Black Ka8
// Black king has no legal moves and is not in check (spec-verified position)
val b = board(
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
)
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Drawn
test("gameStatus: king in check with legal escape returns InCheck"):
// White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7
val b = board(
sq(File.A, Rank.R8) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackKing
)
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.InCheck
test("gameStatus: normal starting position returns Normal"):
GameRules.gameStatus(Board.initial, Color.White) shouldBe PositionStatus.Normal
- Step 2: Run the tests and confirm they all fail
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest"
Expected: all 8 tests FAIL (stubs always return false / Set.empty / Normal)
- Step 3: Commit
git add modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala
git commit -m "test: add failing GameRulesTest for check/checkmate/stalemate"
Task 3: Implement GameRules
Files:
-
Modify:
modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala -
Step 1: Replace the stub bodies with real implementations
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
object GameRules:
def isInCheck(board: Board, color: Color): Boolean =
board.pieces
.collectFirst { case (sq, Piece(`color`, PieceType.King)) => sq }
.exists { kingSq =>
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
MoveValidator.legalTargets(board, sq).contains(kingSq)
}
}
def legalMoves(board: Board, color: Color): Set[(Square, Square)] =
board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(board, from)
.filter { to =>
val (newBoard, _) = board.withMove(from, to)
!isInCheck(newBoard, color)
}
.map(to => from -> to)
}
.toSet
def gameStatus(board: Board, color: Color): PositionStatus =
val moves = legalMoves(board, 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
else PositionStatus.Normal
- Step 2: Run the GameRules tests and confirm they all pass
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest"
Expected: all 8 tests PASS
- Step 3: Run the full test suite to make sure nothing regressed
./gradlew :modules:core:test
Expected: BUILD SUCCESSFUL, all existing tests still pass
- Step 4: Commit
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala
git commit -m "feat: implement GameRules with isInCheck, legalMoves, gameStatus"
Task 4: Add new MoveResult variants and stub processMove
Files:
-
Modify:
modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala -
Step 1: Add three new variants to
MoveResultand importGameRules
In GameController.scala, update the MoveResult object and processMove. The new variants go after Moved. The import of GameRules/PositionStatus is added at the top. The stub processMove calls GameRules.gameStatus but always maps to Moved — this makes it compile while the new tests will fail:
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, Piece}
import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus}
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
object GameController:
def processMove(board: Board, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, from, to) then
MoveResult.IllegalMove
else
val (newBoard, captured) = board.withMove(from, to)
MoveResult.Moved(newBoard, captured, turn.opposite) // stub — Task 6 will fix
def gameLoop(board: Board, turn: Color): Unit =
println()
print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, 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(board, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, turn)
case MoveResult.Moved(newBoard, 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(newBoard, newTurn)
case MoveResult.MovedInCheck(newBoard, captured, newTurn) => // stub — Task 6
gameLoop(newBoard, newTurn)
case MoveResult.Checkmate(winner) => // stub — Task 6
gameLoop(Board.initial, Color.White)
case MoveResult.Stalemate => // stub — Task 6
gameLoop(Board.initial, Color.White)
- Step 2: Confirm everything still compiles and existing tests pass
./gradlew :modules:core:test
Expected: BUILD SUCCESSFUL — existing tests still pass, no compilation errors
- Step 3: Commit
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
git commit -m "feat: add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch)"
Task 5: Write new GameControllerTest cases (all must fail)
Files:
-
Modify:
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala -
Step 1: Append the following tests to the existing file
Add after the last existing test (the gameLoop: capture test). Add the captureOutput helper alongside withInput:
// ──── helpers ────────────────────────────────────────────────────────
private def captureOutput(block: => Unit): String =
val out = java.io.ByteArrayOutputStream()
scala.Console.withOut(out)(block)
out.toString("UTF-8")
// ──── processMove: check / checkmate / stalemate ─────────────────────
test("processMove: legal move that delivers check returns MovedInCheck"):
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, putting Kh8 in check
// (Ra8 attacks along rank 8: b8..h8; king escapes to g7/g8/h7 — InCheck, not Mated)
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, Color.White, "a1a8") match
case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black
case other => fail(s"Expected MovedInCheck, got $other")
test("processMove: legal move that results in checkmate returns Checkmate"):
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1-h8)
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified)
// Note: Qa1 does NOT currently attack Ka8 (path along file A is blocked by Ka6)
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, Color.White, "a1h8") match
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
case other => fail(s"Expected Checkmate(White), got $other")
test("processMove: legal move that results in stalemate returns Stalemate"):
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified)
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, Color.White, "b1b6") match
case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other")
// ──── gameLoop: check / checkmate / stalemate ─────────────────────────
test("gameLoop: checkmate prints winner message and resets to new game"):
// Same position as checkmate processMove test above; after Qa1-Qh8 game resets
// Second move "quit" exits the new game cleanly
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1h8\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("Checkmate! White wins.")
test("gameLoop: stalemate prints draw message and resets to new game"):
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("b1b6\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("Stalemate! The game is a draw.")
test("gameLoop: MovedInCheck without capture prints check message"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("Black is in check!")
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
// White Rook A1 captures Black Pawn on A8, putting Black King (H8) in check
// Ra8 attacks rank 8 → Black Kh8 is in check; king can escape to g7/g8/h7
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R3) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackPawn,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
GameController.gameLoop(b, Color.White)
output should include("captures")
output should include("Black is in check!")
- Step 2: Run only the new tests and confirm they fail
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
Expected: the 7 new tests FAIL; the existing 17 tests PASS
- Step 3: Commit
git add modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "test: add failing GameControllerTest cases for check/checkmate/stalemate"
Task 6: Implement processMove dispatch and gameLoop branches
Files:
-
Modify:
modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala -
Step 1: Replace the stub
processMoveelse-branch and the three stubgameLoopcases
Replace only the else branch inside processMove (keep everything else identical):
else
val (newBoard, captured) = board.withMove(from, to)
GameRules.gameStatus(newBoard, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
Replace the three stub gameLoop cases:
case MoveResult.MovedInCheck(newBoard, 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(newBoard, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, Color.White)
- Step 2: Run all controller tests
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
Expected: all 24 tests PASS
- Step 3: Run the full test suite
./gradlew :modules:core:test
Expected: BUILD SUCCESSFUL, all tests pass
- Step 4: Commit
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
git commit -m "feat: wire check/checkmate/stalemate into processMove and gameLoop"
Task 7: Coverage check and final verification
- Step 1: Run the full build with coverage
./gradlew :modules:core:test
Expected: BUILD SUCCESSFUL
- Step 2: Check coverage gaps
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml
Review output. If any newly added method falls below the thresholds from CLAUDE.md (branch ≥ 90%, line ≥ 95%, method ≥ 90%), add targeted tests to close the gaps before considering the task done.
- Step 3: Commit coverage fixes (if any)
git add -p
git commit -m "test: improve coverage for GameRules and GameController"