diff --git a/.claude/agent-memory/scala-implementer/project_chess_tui.md b/.claude/agent-memory/scala-implementer/project_chess_tui.md index 88248c8..045e148 100644 --- a/.claude/agent-memory/scala-implementer/project_chess_tui.md +++ b/.claude/agent-memory/scala-implementer/project_chess_tui.md @@ -1,14 +1,20 @@ --- name: chess_tui_implementation -description: Chess TUI implemented in modules/core under de.nowchess.chess — model, renderer, parser, game loop +description: Chess TUI in modules/core — MVC sub-packages under de.nowchess.chess type: project --- -Chess TUI standalone app implemented in `modules/core`, package `de.nowchess.chess`. +Chess TUI standalone app implemented in `modules/core`, root package `de.nowchess.chess`. -**Why:** Initial feature to demonstrate the system's TUI capability per ADR-001. +**Why:** Initial feature to demonstrate the system's TUI capability per ADR-001. Refactored to MVC pattern to separate concerns. -**How to apply:** When extending the chess logic (legality, castling, en passant, promotion), build on the existing `Model.scala` opaque `Board` type and add methods via extension. The `@main` entry point is `chessMain` in `Game.scala`. `Test.scala` still exists as a separate hello-world stub — do not remove it. +**How to apply:** When extending chess logic (legality, castling, en passant, promotion), build on the existing `Board` opaque type in `model` and add extension methods there. The `@main` entry point is `chessMain` in `Main.scala` (root package). Game loop lives in `GameController`. + +Package layout after MVC refactor: +- `de.nowchess.chess.model` — `Model.scala`: `Color`, `PieceType`, `Piece`, `Square`, `Board` (opaque type) +- `de.nowchess.chess.view` — `Renderer.scala`: ANSI board renderer +- `de.nowchess.chess.controller` — `Parser.scala`: coordinate-notation parser; `GameController.scala`: game loop +- `de.nowchess.chess` — `Main.scala`: `@main def chessMain()` Key design choices: - `Board` is an opaque type over `Map[Square, Piece]` with extension methods diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md index 39cade9..5fe84cc 100644 --- a/.claude/agents/architect.md +++ b/.claude/agents/architect.md @@ -11,4 +11,4 @@ You are a software architect specialising in microservice design. Define OpenAPI contracts before implementation begins. Save all contracts to /docs/api/{service-name}.yaml Save all ADRs to /docs/adr/ -Never write implementation code. +**Never write implementation code.** diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..56504ed --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew :modules:core:test)" + ] + } +} diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml index 226a52a..bf3a94f 100644 --- a/.idea/scala_compiler.xml +++ b/.idea/scala_compiler.xml @@ -1,9 +1,17 @@ - + + + \ No newline at end of file diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Board.scala b/modules/api/src/main/scala/de/nowchess/api/board/Board.scala new file mode 100644 index 0000000..e082054 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/board/Board.scala @@ -0,0 +1,31 @@ +package de.nowchess.api.board + +opaque type Board = Map[Square, Piece] + +object Board: + + def apply(pieces: Map[Square, Piece]): Board = pieces + + extension (b: Board) + def pieceAt(sq: Square): Option[Piece] = b.get(sq) + def withMove(from: Square, to: Square): (Board, Option[Piece]) = + val captured = b.get(to) + val updated = b.removed(from).updated(to, b(from)) + (updated, captured) + def pieces: Map[Square, Piece] = b + + val initial: Board = + val backRank: Vector[PieceType] = Vector( + PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, + PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook + ) + val entries = for + fileIdx <- 0 until 8 + (color, rank, pieceType) <- Seq( + (Color.White, Rank.R1, backRank(fileIdx)), + (Color.White, Rank.R2, PieceType.Pawn), + (Color.Black, Rank.R8, backRank(fileIdx)), + (Color.Black, Rank.R7, PieceType.Pawn) + ) + yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType) + Board(entries.toMap) diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Color.scala b/modules/api/src/main/scala/de/nowchess/api/board/Color.scala index 420923a..31947d4 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/Color.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/Color.scala @@ -6,3 +6,7 @@ enum Color: def opposite: Color = this match case White => Black case Black => White + + def label: String = this match + case White => "White" + case Black => "Black" diff --git a/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala b/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala index 4bebd59..324eb58 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala @@ -2,3 +2,11 @@ package de.nowchess.api.board enum PieceType: case Pawn, Knight, Bishop, Rook, Queen, King + + def label: String = this match + case Pawn => "Pawn" + case Knight => "Knight" + case Bishop => "Bishop" + case Rook => "Rook" + case Queen => "Queen" + case King => "King" diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index 035dcb7..f4c373d 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -22,6 +22,10 @@ application { mainClass.set("de.nowchess.chess.chessMain") } +tasks.withType { + scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") +} + tasks.named("run") { jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8") standardInput = System.`in` diff --git a/modules/core/src/main/scala/de/nowchess/chess/Game.scala b/modules/core/src/main/scala/de/nowchess/chess/Game.scala deleted file mode 100644 index 17bdeb4..0000000 --- a/modules/core/src/main/scala/de/nowchess/chess/Game.scala +++ /dev/null @@ -1,31 +0,0 @@ -package de.nowchess.chess - -import scala.io.StdIn - -@main def chessMain(): Unit = - println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") - gameLoop(Board.initial, Color.White) - -private 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 - input match - case "quit" | "q" => - println("Game over. Goodbye!") - case raw => - Parser.parseMove(raw) match - case None => - println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") - gameLoop(board, turn) - case Some((from, to)) => - board.pieceAt(from) match - case None => - println(s"No piece on ${from.label}.") - gameLoop(board, turn) - case Some(movingPiece) => - val (newBoard, captured) = board.withMove(from, to) - captured.foreach: cap => - println(s"${turn.label} captures ${cap.color.label} ${cap.pieceType.label} on ${to.label}") - gameLoop(newBoard, turn.opposite) diff --git a/modules/core/src/main/scala/de/nowchess/chess/Main.scala b/modules/core/src/main/scala/de/nowchess/chess/Main.scala new file mode 100644 index 0000000..b1d63db --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/Main.scala @@ -0,0 +1,8 @@ +package de.nowchess.chess + +import de.nowchess.api.board.{Board, Color} +import de.nowchess.chess.controller.GameController + +@main def chessMain(): Unit = + println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") + GameController.gameLoop(Board.initial, Color.White) diff --git a/modules/core/src/main/scala/de/nowchess/chess/Model.scala b/modules/core/src/main/scala/de/nowchess/chess/Model.scala deleted file mode 100644 index db53aa2..0000000 --- a/modules/core/src/main/scala/de/nowchess/chess/Model.scala +++ /dev/null @@ -1,82 +0,0 @@ -package de.nowchess.chess - -enum Color: - case White, Black - - def opposite: Color = this match - case White => Black - case Black => White - - def label: String = this match - case White => "White" - case Black => "Black" - -enum PieceType: - case King, Queen, Rook, Bishop, Knight, Pawn - - def label: String = this match - case King => "King" - case Queen => "Queen" - case Rook => "Rook" - case Bishop => "Bishop" - case Knight => "Knight" - case Pawn => "Pawn" - -final case class Piece(color: Color, pieceType: PieceType): - def unicode: String = (color, pieceType) match - case (Color.White, PieceType.King) => "\u2654" - case (Color.White, PieceType.Queen) => "\u2655" - case (Color.White, PieceType.Rook) => "\u2656" - case (Color.White, PieceType.Bishop) => "\u2657" - case (Color.White, PieceType.Knight) => "\u2658" - case (Color.White, PieceType.Pawn) => "\u2659" - case (Color.Black, PieceType.King) => "\u265A" - case (Color.Black, PieceType.Queen) => "\u265B" - case (Color.Black, PieceType.Rook) => "\u265C" - case (Color.Black, PieceType.Bishop) => "\u265D" - case (Color.Black, PieceType.Knight) => "\u265E" - case (Color.Black, PieceType.Pawn) => "\u265F" - -/** Zero-based file (0=a..7=h) and rank (0=rank1..7=rank8). */ -final case class Square(file: Int, rank: Int): - require(file >= 0 && file <= 7 && rank >= 0 && rank <= 7, s"Square out of bounds: $file,$rank") - - def label: String = s"${('a' + file).toChar}${rank + 1}" - -opaque type Board = Map[Square, Piece] - -object Board: - def apply(pieces: Map[Square, Piece]): Board = pieces - - extension (b: Board) - def pieceAt(sq: Square): Option[Piece] = b.get(sq) - def withMove(from: Square, to: Square): (Board, Option[Piece]) = - val captured = b.get(to) - val updated = b.removed(from).updated(to, b(from)) - (updated, captured) - def pieces: Map[Square, Piece] = b - - val initial: Board = - val backRank: Vector[PieceType] = - Vector( - PieceType.Rook, - PieceType.Knight, - PieceType.Bishop, - PieceType.Queen, - PieceType.King, - PieceType.Bishop, - PieceType.Knight, - PieceType.Rook - ) - - val entries = for - file <- 0 until 8 - (color, rank, row) <- Seq( - (Color.White, 0, backRank(file)), - (Color.White, 1, PieceType.Pawn), - (Color.Black, 7, backRank(file)), - (Color.Black, 6, PieceType.Pawn) - ) - yield Square(file, rank) -> Piece(color, row) - - Board(entries.toMap) 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 new file mode 100644 index 0000000..242ab43 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -0,0 +1,31 @@ +package de.nowchess.chess.controller + +import scala.io.StdIn +import de.nowchess.api.board.{Board, Color} +import de.nowchess.chess.view.Renderer + +object GameController: + + 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 + input match + case "quit" | "q" => + println("Game over. Goodbye!") + case raw => + Parser.parseMove(raw) match + case None => + println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") + gameLoop(board, turn) + case Some((from, to)) => + board.pieceAt(from) match + case None => + println(s"No piece on ${from.toString}.") + gameLoop(board, turn) + case Some(_) => + val (newBoard, captured) = board.withMove(from, to) + captured.foreach: cap => + println(s"${turn.label} captures ${cap.color.label} ${cap.pieceType.label} on ${to.toString}") + gameLoop(newBoard, turn.opposite) diff --git a/modules/core/src/main/scala/de/nowchess/chess/Parser.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala similarity index 64% rename from modules/core/src/main/scala/de/nowchess/chess/Parser.scala rename to modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala index c4bc967..3e95cb5 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/Parser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala @@ -1,4 +1,6 @@ -package de.nowchess.chess +package de.nowchess.chess.controller + +import de.nowchess.api.board.{File, Rank, Square} object Parser: @@ -15,6 +17,8 @@ object Parser: private def parseSquare(s: String): Option[Square] = Option.when(s.length == 2)(s).flatMap: sq => - val file = sq(0) - 'a' - val rank = sq(1) - '1' - Option.when(file >= 0 && file <= 7 && rank >= 0 && rank <= 7)(Square(file, rank)) + val fileIdx = sq(0) - 'a' + val rankIdx = sq(1) - '1' + Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)( + Square(File.values(fileIdx), Rank.values(rankIdx)) + ) diff --git a/modules/core/src/main/scala/de/nowchess/chess/view/PieceUnicode.scala b/modules/core/src/main/scala/de/nowchess/chess/view/PieceUnicode.scala new file mode 100644 index 0000000..db1210a --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/view/PieceUnicode.scala @@ -0,0 +1,18 @@ +package de.nowchess.chess.view + +import de.nowchess.api.board.{Color, Piece, PieceType} + +extension (p: Piece) + def unicode: String = (p.color, p.pieceType) match + case (Color.White, PieceType.King) => "\u2654" + case (Color.White, PieceType.Queen) => "\u2655" + case (Color.White, PieceType.Rook) => "\u2656" + case (Color.White, PieceType.Bishop) => "\u2657" + case (Color.White, PieceType.Knight) => "\u2658" + case (Color.White, PieceType.Pawn) => "\u2659" + case (Color.Black, PieceType.King) => "\u265A" + case (Color.Black, PieceType.Queen) => "\u265B" + case (Color.Black, PieceType.Rook) => "\u265C" + case (Color.Black, PieceType.Bishop) => "\u265D" + case (Color.Black, PieceType.Knight) => "\u265E" + case (Color.Black, PieceType.Pawn) => "\u265F" diff --git a/modules/core/src/main/scala/de/nowchess/chess/Renderer.scala b/modules/core/src/main/scala/de/nowchess/chess/view/Renderer.scala similarity index 86% rename from modules/core/src/main/scala/de/nowchess/chess/Renderer.scala rename to modules/core/src/main/scala/de/nowchess/chess/view/Renderer.scala index 8e8e7b5..3a7fafa 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/Renderer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/view/Renderer.scala @@ -1,4 +1,6 @@ -package de.nowchess.chess +package de.nowchess.chess.view + +import de.nowchess.api.board.{Board, Color, File, Rank, Square} object Renderer: @@ -14,7 +16,7 @@ object Renderer: for rank <- (0 until 8).reverse do sb.append(s"${rank + 1} ") for file <- 0 until 8 do - val sq = Square(file, rank) + val sq = Square(File.values(file), Rank.values(rank)) val isLightSq = (file + rank) % 2 != 0 val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare val cellContent = board.pieceAt(sq) match diff --git a/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala b/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala index d25ba98..c9bc06b 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala @@ -1,5 +1,7 @@ package de.nowchess.chess +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.chess.view.unicode import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.* @@ -10,9 +12,9 @@ class ModelTest: assertEquals(Color.White, Color.Black.opposite) @Test def squareLabel(): Unit = - assertEquals("a1", Square(0, 0).label) - assertEquals("e4", Square(4, 3).label) - assertEquals("h8", Square(7, 7).label) + assertEquals("a1", Square(File.A, Rank.R1).toString) + assertEquals("e4", Square(File.E, Rank.R4).toString) + assertEquals("h8", Square(File.H, Rank.R8).toString) @Test def pieceUnicode(): Unit = assertEquals("\u2654", Piece(Color.White, PieceType.King).unicode) @@ -24,36 +26,34 @@ class ModelTest: assertEquals(32, Board.initial.pieces.size) @Test def initialWhiteKingOnE1(): Unit = - val e1 = Square(4, 0) + val e1 = Square(File.E, Rank.R1) assertEquals(Some(Piece(Color.White, PieceType.King)), Board.initial.pieceAt(e1)) @Test def initialBlackQueenOnD8(): Unit = - val d8 = Square(3, 7) + val d8 = Square(File.D, Rank.R8) assertEquals(Some(Piece(Color.Black, PieceType.Queen)), Board.initial.pieceAt(d8)) @Test def initialWhitePawnsOnRank2(): Unit = - for file <- 0 until 8 do - val sq = Square(file, 1) + for file <- File.values do + val sq = Square(file, Rank.R2) assertEquals(Some(Piece(Color.White, PieceType.Pawn)), Board.initial.pieceAt(sq)) @Test def withMoveMovesAndLeavesOriginEmpty(): Unit = - val e2 = Square(4, 1) - val e4 = Square(4, 3) + val e2 = Square(File.E, Rank.R2) + val e4 = Square(File.E, Rank.R4) val (newBoard, captured) = Board.initial.withMove(e2, e4) assertEquals(None, newBoard.pieceAt(e2)) assertEquals(Some(Piece(Color.White, PieceType.Pawn)), newBoard.pieceAt(e4)) assertEquals(None, captured) @Test def withMoveCaptureReturnsCapture(): Unit = - // Place a black pawn on e4 and a white pawn already there via two moves - val e2 = Square(4, 1) - val e4 = Square(4, 3) + val e2 = Square(File.E, Rank.R2) + val e4 = Square(File.E, Rank.R4) val (board2, _) = Board.initial.withMove(e2, e4) - // Place black pawn on d4 manually for capture test - val d7 = Square(3, 6) - val d4 = Square(3, 3) + val d7 = Square(File.D, Rank.R7) + val d4 = Square(File.D, Rank.R4) val (board3, _) = board2.withMove(d7, d4) - // Now white pawn on e4 captures black pawn on d4 (diagonal — no legality check) + // White pawn on e4 captures black pawn on d4 (diagonal — no legality check) val (board4, cap) = board3.withMove(e4, d4) assertEquals(Some(Piece(Color.Black, PieceType.Pawn)), cap) assertEquals(Some(Piece(Color.White, PieceType.Pawn)), board4.pieceAt(d4)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala index 34a7140..5650c68 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala @@ -1,18 +1,20 @@ package de.nowchess.chess +import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.chess.controller.Parser import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.* class ParserTest: @Test def parsesValidMove(): Unit = - assertEquals(Some((Square(4, 1), Square(4, 3))), Parser.parseMove("e2e4")) + assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), Parser.parseMove("e2e4")) @Test def parsesKnightMove(): Unit = - assertEquals(Some((Square(6, 0), Square(5, 2))), Parser.parseMove("g1f3")) + assertEquals(Some((Square(File.G, Rank.R1), Square(File.F, Rank.R3))), Parser.parseMove("g1f3")) @Test def ignoresExtraWhitespace(): Unit = - assertEquals(Some((Square(4, 1), Square(4, 3))), Parser.parseMove(" e2e4 ")) + assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), Parser.parseMove(" e2e4 ")) @Test def rejectsShortInput(): Unit = assertEquals(None, Parser.parseMove("e2e")) @@ -27,5 +29,5 @@ class ParserTest: assertEquals(None, Parser.parseMove("e9e4")) @Test def parsesUppercaseAsInvalid(): Unit = - // uppercase files are out of range after toLowerCase — stays lowercase internally - assertEquals(Some((Square(4, 1), Square(4, 3))), Parser.parseMove("E2E4")) + // Input is lowercased before parsing, so "E2E4" -> "e2e4" -> valid + assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), Parser.parseMove("E2E4")) diff --git a/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala b/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala index 1fb8a16..d15cf49 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala @@ -1,5 +1,7 @@ package de.nowchess.chess +import de.nowchess.api.board.Board +import de.nowchess.chess.view.Renderer import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.*