chore: Set up shared-models library and initial project structure for NowChessSystems

This commit is contained in:
2026-03-21 17:07:28 +01:00
parent a8d457a612
commit 9c2456e928
28 changed files with 815 additions and 12 deletions
@@ -1,9 +0,0 @@
package de.nowchess
object Test {
def main(args: Array[String]): Unit = {
println("Hello World")
}
}
@@ -0,0 +1,31 @@
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,82 @@
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,20 @@
package de.nowchess.chess
object Parser:
/** Parses coordinate notation such as "e2e4" or "g1f3".
* Returns None for any input that does not match the expected format.
*/
def parseMove(input: String): Option[(Square, Square)] =
val trimmed = input.trim.toLowerCase
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
for
from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4))
yield (from, to)
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))
@@ -0,0 +1,29 @@
package de.nowchess.chess
object Renderer:
private val AnsiReset = "\u001b[0m"
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
private val AnsiWhitePiece = "\u001b[97m" // bright white text
private val AnsiBlackPiece = "\u001b[30m" // black text
def render(board: Board): String =
val sb = new StringBuilder
sb.append(" a b c d e f g h\n")
for rank <- (0 until 8).reverse do
sb.append(s"${rank + 1} ")
for file <- 0 until 8 do
val sq = Square(file, rank)
val isLightSq = (file + rank) % 2 != 0
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
val cellContent = board.pieceAt(sq) match
case Some(piece) =>
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
case None =>
s"$bgColor $AnsiReset"
sb.append(cellContent)
sb.append(s" ${rank + 1}\n")
sb.append(" a b c d e f g h\n")
sb.toString