diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index a9cffac..6ee256e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -1,7 +1,8 @@ package de.nowchess.chess.controller import scala.io.StdIn -import de.nowchess.api.board.{Board, Color, Piece} +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.view.Renderer @@ -54,13 +55,43 @@ object GameController: val (newBoard, captured) = castleOpt match case Some(side) => (ctx.board.withCastle(turn, side), None) case None => ctx.board.withMove(from, to) - val newCtx = ctx.copy(board = newBoard) + 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 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. */ diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 7004f7c..ac651aa 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -241,3 +241,167 @@ class GameControllerTest extends AnyFunSuite with Matchers: newCtx.board.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) newCtx.board.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) case other => fail(s"Expected Moved, got $other") + + // ──── rights revocation ────────────────────────────────────────────── + + test("processMove: e1g1 revokes both white castling rights"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling shouldBe CastlingRights.None + case other => fail(s"Expected Moved, got $other") + + test("processMove: moving rook from h1 revokes white kingside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "h1h4") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + newCtx.whiteCastling.queenSide shouldBe true + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + newCtx.whiteCastling.queenSide shouldBe true + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: moving king from e1 revokes both white rights"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1e2") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling shouldBe CastlingRights.None + case other => fail(s"Expected Moved, got $other") + + test("processMove: enemy capture on h1 revokes white kingside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R2) -> Piece.BlackRook, + sq(File.A, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.Black, "h2h1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: castle attempt when rights revoked returns IllegalMove"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove + + test("processMove: castle attempt when rook not on home square returns IllegalMove"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.G, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove + + test("processMove: moving king from e8 revokes both black rights"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.Both + ) + GameController.processMove(ctx, Color.Black, "e8e7") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.blackCastling shouldBe CastlingRights.None + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.blackCastling shouldBe CastlingRights.None + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: moving rook from a8 revokes black queenside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.A, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.Both + ) + GameController.processMove(ctx, Color.Black, "a8a7") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.blackCastling.queenSide shouldBe false + newCtx.blackCastling.kingSide shouldBe true + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.blackCastling.queenSide shouldBe false + newCtx.blackCastling.kingSide shouldBe true + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: moving rook from h8 revokes black kingside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.Both + ) + GameController.processMove(ctx, Color.Black, "h8h7") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.blackCastling.kingSide shouldBe false + newCtx.blackCastling.queenSide shouldBe true + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.blackCastling.kingSide shouldBe false + newCtx.blackCastling.queenSide shouldBe true + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: enemy capture on a1 revokes white queenside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.A, Rank.R2) -> Piece.BlackRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.Black, "a2a1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling.queenSide shouldBe false + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.whiteCastling.queenSide shouldBe false + case other => fail(s"Expected Moved or MovedInCheck, got $other")