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.*