chore: Refactor chess logic to MVC pattern and enhance board representation
This commit is contained in:
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -22,6 +22,10 @@ application {
|
||||
mainClass.set("de.nowchess.chess.chessMain")
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
tasks.named<JavaExec>("run") {
|
||||
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
|
||||
standardInput = System.`in`
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+8
-4
@@ -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))
|
||||
)
|
||||
@@ -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"
|
||||
+4
-2
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user