chore: Add initial implementation of move validation logic and game controller

This commit is contained in:
2026-03-21 21:26:37 +01:00
parent 1f1d3b670f
commit a8abd69e0e
20 changed files with 1755 additions and 31 deletions
@@ -1,31 +1,80 @@
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.board.{Board, Color, Piece}
import de.nowchess.chess.logic.MoveValidator
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
object GameController:
/** Pure function: interprets one raw input line against the current board state.
* Has no I/O side effects all output must be handled by the caller.
*/
def processMove(board: Board, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, from, to) then
MoveResult.IllegalMove
else
val (newBoard, captured) = board.withMove(from, to)
MoveResult.Moved(newBoard, captured, turn.opposite)
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
* Behaviour is identical to the original implementation.
*/
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" =>
processMove(board, turn, input) match
case MoveResult.Quit =>
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)
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, turn)
case MoveResult.Moved(newBoard, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newTurn)
@@ -0,0 +1,112 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
object MoveValidator:
/** Returns true if the move is geometrically legal for the piece on `from`,
* ignoring check/pin but respecting:
* - correct movement pattern for the piece type
* - cannot capture own pieces
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
*/
def isLegal(board: Board, from: Square, to: Square): Boolean =
legalTargets(board, from).contains(to)
/** All squares a piece on `from` can legally move to (same rules as isLegal). */
def legalTargets(board: Board, from: Square): Set[Square] =
board.pieceAt(from) match
case None => Set.empty
case Some(piece) =>
piece.pieceType match
case PieceType.Pawn => pawnTargets(board, from, piece.color)
case PieceType.Knight => knightTargets(board, from, piece.color)
case PieceType.Bishop => slide(board, from, piece.color, diagonalDeltas)
case PieceType.Rook => slide(board, from, piece.color, orthogonalDeltas)
case PieceType.Queen => slide(board, from, piece.color, diagonalDeltas ++ orthogonalDeltas)
case PieceType.King => kingTargets(board, from, piece.color)
// ── helpers ────────────────────────────────────────────────────────────────
private val diagonalDeltas: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
private val orthogonalDeltas: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
private val knightDeltas: List[(Int, Int)] =
List((1, 2), (1, -2), (-1, 2), (-1, -2), (2, 1), (2, -1), (-2, 1), (-2, -1))
/** Try to construct a Square from integer file/rank indices (0-based). */
private def squareAt(fileIdx: Int, rankIdx: Int): Option[Square] =
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx))
)
/** True when `sq` is occupied by a piece of `color`. */
private def isOwnPiece(board: Board, sq: Square, color: Color): Boolean =
board.pieceAt(sq).exists(_.color == color)
/** True when `sq` is occupied by a piece of the opposite color. */
private def isEnemyPiece(board: Board, sq: Square, color: Color): Boolean =
board.pieceAt(sq).exists(_.color != color)
/** Sliding move generation along a list of direction deltas.
* Each direction continues until the board edge, an own piece, or the first
* enemy piece (which is included as a capture target).
*/
private def slide(board: Board, from: Square, color: Color, deltas: List[(Int, Int)]): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
deltas.flatMap: (df, dr) =>
Iterator
.iterate((fi + df, ri + dr)) { case (f, r) => (f + df, r + dr) }
.takeWhile { case (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 }
.map { case (f, r) => Square(File.values(f), Rank.values(r)) }
.foldLeft((List.empty[Square], false)):
case ((acc, stopped), sq) =>
if stopped then (acc, true)
else if isOwnPiece(board, sq, color) then (acc, true) // blocked — stop, no capture
else if isEnemyPiece(board, sq, color) then (acc :+ sq, true) // capture — stop after
else (acc :+ sq, false) // empty — continue
._1
.toSet
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
val oneStep = squareAt(fi, ri + dir)
// Forward one square (only if empty)
val forward1: Set[Square] = oneStep match
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
case _ => Set.empty
// Forward two squares from starting rank (only if both intermediate squares are empty)
val forward2: Set[Square] =
if ri == startRank && forward1.nonEmpty then
squareAt(fi, ri + 2 * dir) match
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
case _ => Set.empty
else Set.empty
// Diagonal captures (only if enemy piece present)
val captures: Set[Square] =
List(-1, 1).flatMap: df =>
squareAt(fi + df, ri + dir).filter(sq => isEnemyPiece(board, sq, color))
.toSet
forward1 ++ forward2 ++ captures
private def knightTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
knightDeltas.flatMap: (df, dr) =>
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
.toSet
private def kingTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
.toSet