diff --git a/CODE_NOTICE.md b/CODE_NOTICE.md new file mode 100644 index 0000000..838c945 --- /dev/null +++ b/CODE_NOTICE.md @@ -0,0 +1,41 @@ +# 🔒 CODE FREEZE NOTICE + +## Date: March 29, 2026 +## Duration: Core Separation Refactor + +### Reason +Implementing Command Pattern and Observer Pattern to decouple UI and logic interfaces. + +### Scope +This refactor will: +1. Extract TUI code from `core` module into standalone UI module +2. Implement Command Pattern for all user interactions +3. Implement Observer Pattern for state change notifications +4. Make `core` completely UI-agnostic +5. Enable multiple simultaneous UIs (TUI + future ScalaFX GUI) + +### Module Structure (Target) +``` +modules/ + core/ # Pure game logic, Command, Observer traits, CommandInvoker + api/ # Data models (unchanged) + ui/ # TUI and GUI implementations (both depend only on core) +``` + +### Expected Impact +- All regression tests must pass +- Build must succeed with new module structure +- Core contains zero UI references +- TUI and potential GUI can run independently or simultaneously + +### Blocked Changes +Do not: +- Add new features to `core` +- Modify `core` API before Message & Observer traits are implemented +- Create direct dependencies between UI modules +- Add UI code to `core` + +Keep developing in separate branches until refactor is complete. + +--- +Status: **IN PROGRESS** ✏️ diff --git a/modules/core/src/main/scala/de/nowchess/chess/Main.scala b/modules/core/src/main/scala/de/nowchess/chess/Main.scala deleted file mode 100644 index 3fb72e6..0000000 --- a/modules/core/src/main/scala/de/nowchess/chess/Main.scala +++ /dev/null @@ -1,12 +0,0 @@ -package de.nowchess.chess - -import de.nowchess.api.board.Board -import de.nowchess.api.board.Color -import de.nowchess.chess.controller.GameController -import de.nowchess.chess.logic.GameHistory - -object Main { - def main(args: Array[String]): Unit = - println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") - GameController.gameLoop(Board.initial, GameHistory.empty, Color.White) -} diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala new file mode 100644 index 0000000..d8c5d98 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala @@ -0,0 +1,34 @@ +package de.nowchess.chess.command + +import de.nowchess.api.board.Square + +/** Marker trait for all commands that can be executed and undone. + * Commands encapsulate user actions and game state transitions. + */ +trait Command: + /** Execute the command and return true if successful, false otherwise. */ + def execute(): Boolean + + /** Undo the command and return true if successful, false otherwise. */ + def undo(): Boolean + + /** A human-readable description of this command. */ + def description: String + +/** Command to move a piece from one square to another. */ +case class MoveCommand(from: Square, to: Square) extends Command: + override def execute(): Boolean = true + override def undo(): Boolean = true + override def description: String = s"Move from $from to $to" + +/** Command to quit the game. */ +case class QuitCommand() extends Command: + override def execute(): Boolean = true + override def undo(): Boolean = false + override def description: String = "Quit game" + +/** Command to reset the board to initial position. */ +case class ResetCommand() extends Command: + override def execute(): Boolean = true + override def undo(): Boolean = true + override def description: String = "Reset board" diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala new file mode 100644 index 0000000..af404f9 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala @@ -0,0 +1,61 @@ +package de.nowchess.chess.command + +/** Manages command execution and history for undo/redo support. */ +class CommandInvoker: + private val executedCommands = scala.collection.mutable.ListBuffer[Command]() + private var currentIndex = -1 + + /** Execute a command and add it to history. + * Discards any redo history if not at the end of the stack. + */ + def execute(command: Command): Boolean = + if command.execute() then + // Remove any commands after current index (redo stack is discarded) + while currentIndex < executedCommands.size - 1 do + executedCommands.remove(executedCommands.size - 1) + executedCommands += command + currentIndex += 1 + true + else + false + + /** Undo the last executed command if possible. */ + def undo(): Boolean = + if currentIndex >= 0 && currentIndex < executedCommands.size then + val command = executedCommands(currentIndex) + if command.undo() then + currentIndex -= 1 + true + else + false + else + false + + /** Redo the next command in history if available. */ + def redo(): Boolean = + if currentIndex + 1 < executedCommands.size then + val command = executedCommands(currentIndex + 1) + if command.execute() then + currentIndex += 1 + true + else + false + else + false + + /** Get the history of all executed commands. */ + def history: List[Command] = executedCommands.toList + + /** Get the current position in command history. */ + def getCurrentIndex: Int = currentIndex + + /** Clear all command history. */ + def clear(): Unit = + executedCommands.clear() + currentIndex = -1 + + /** Check if undo is available. */ + def canUndo: Boolean = currentIndex >= 0 + + /** Check if redo is available. */ + def canRedo: Boolean = currentIndex + 1 < executedCommands.size 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 index 4717430..120b9e9 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -1,9 +1,7 @@ package de.nowchess.chess.controller -import scala.io.StdIn import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} import de.nowchess.chess.logic.* -import de.nowchess.chess.view.Renderer // --------------------------------------------------------------------------- // Result ADT returned by the pure processMove function @@ -66,46 +64,3 @@ object GameController: case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Drawn => MoveResult.Stalemate - - /** Thin I/O shell: renders the board, reads a line, delegates to processMove, - * prints the outcome, and recurses until the game ends. - */ - def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = - println() - print(Renderer.render(board)) - println(s"${turn.label}'s turn. Enter move: ") - val input = Option(StdIn.readLine()).getOrElse("quit").trim - processMove(board, history, turn, input) match - case MoveResult.Quit => - println("Game over. Goodbye!") - case MoveResult.InvalidFormat(raw) => - println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") - gameLoop(board, history, turn) - case MoveResult.NoPiece => - println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") - gameLoop(board, history, turn) - case MoveResult.WrongColor => - println(s"That is not your piece.") - gameLoop(board, history, turn) - case MoveResult.IllegalMove => - println(s"Illegal move.") - gameLoop(board, history, turn) - case MoveResult.Moved(newBoard, newHistory, 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, newHistory, newTurn) - case MoveResult.MovedInCheck(newBoard, newHistory, 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") - println(s"${newTurn.label} is in check!") - gameLoop(newBoard, newHistory, newTurn) - case MoveResult.Checkmate(winner) => - println(s"Checkmate! ${winner.label} wins.") - gameLoop(Board.initial, GameHistory.empty, Color.White) - case MoveResult.Stalemate => - println("Stalemate! The game is a draw.") - gameLoop(Board.initial, GameHistory.empty, Color.White) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala new file mode 100644 index 0000000..4f0c5b9 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -0,0 +1,140 @@ +package de.nowchess.chess.engine + +import de.nowchess.api.board.{Board, Color, Piece} +import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus} +import de.nowchess.chess.controller.{GameController, Parser, MoveResult} +import de.nowchess.chess.observer.* + +/** Pure game engine that manages game state and notifies observers of state changes. + * This class is the single source of truth for the game state. + * All user interactions must go through this engine, and all state changes + * are communicated to observers via GameEvent notifications. + */ +class GameEngine extends Observable: + private var currentBoard: Board = Board.initial + private var currentHistory: GameHistory = GameHistory.empty + private var currentTurn: Color = Color.White + + // Synchronized accessors for current state + def board: Board = synchronized { currentBoard } + def history: GameHistory = synchronized { currentHistory } + def turn: Color = synchronized { currentTurn } + + /** Process a raw move input string and update game state if valid. + * Notifies all observers of the outcome via GameEvent. + */ + def processUserInput(rawInput: String): Unit = synchronized { + GameController.processMove(currentBoard, currentHistory, currentTurn, rawInput) match + case MoveResult.Quit => + // Client should handle quit logic; we just return + () + + case MoveResult.InvalidFormat(raw) => + val event = InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4." + ) + notifyObservers(event) + + case MoveResult.NoPiece => + val event = InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + s"No piece on that square." + ) + notifyObservers(event) + + case MoveResult.WrongColor => + val event = InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + "That is not your piece." + ) + notifyObservers(event) + + case MoveResult.IllegalMove => + val event = InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + "Illegal move." + ) + notifyObservers(event) + + case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => + currentBoard = newBoard + currentHistory = newHistory + currentTurn = newTurn + val fromSq = Parser.parseMove(rawInput.trim).map(_._1).fold("?")(_.toString) + val toSq = Parser.parseMove(rawInput.trim).map(_._2).fold("?")(_.toString) + val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}") + notifyObservers(MoveExecutedEvent( + currentBoard, + currentHistory, + currentTurn, + fromSq, + toSq, + capturedDesc + )) + + case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => + currentBoard = newBoard + currentHistory = newHistory + currentTurn = newTurn + val fromSq = Parser.parseMove(rawInput.trim).map(_._1).fold("?")(_.toString) + val toSq = Parser.parseMove(rawInput.trim).map(_._2).fold("?")(_.toString) + val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}") + notifyObservers(MoveExecutedEvent( + currentBoard, + currentHistory, + currentTurn, + fromSq, + toSq, + capturedDesc + )) + notifyObservers(CheckDetectedEvent( + currentBoard, + currentHistory, + currentTurn + )) + + case MoveResult.Checkmate(winner) => + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(CheckmateEvent( + currentBoard, + currentHistory, + currentTurn, + winner + )) + + case MoveResult.Stalemate => + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(StalemateEvent( + currentBoard, + currentHistory, + currentTurn + )) + } + + /** Reset the board to initial position. + * Notifies all observers of the reset. + */ + def reset(): Unit = synchronized { + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(BoardResetEvent( + currentBoard, + currentHistory, + currentTurn + )) + } +end GameEngine diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala new file mode 100644 index 0000000..f55055a --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -0,0 +1,82 @@ +package de.nowchess.chess.observer + +import de.nowchess.api.board.{Board, Color} +import de.nowchess.chess.logic.GameHistory + +/** Base trait for all game state events. + * Events are immutable snapshots of game state changes. + */ +sealed trait GameEvent: + def board: Board + def history: GameHistory + def turn: Color + +/** Fired when a move is successfully executed. */ +case class MoveExecutedEvent( + board: Board, + history: GameHistory, + turn: Color, + fromSquare: String, + toSquare: String, + capturedPiece: Option[String] +) extends GameEvent + +/** Fired when the current player is in check. */ +case class CheckDetectedEvent( + board: Board, + history: GameHistory, + turn: Color +) extends GameEvent + +/** Fired when the game reaches checkmate. */ +case class CheckmateEvent( + board: Board, + history: GameHistory, + turn: Color, + winner: Color +) extends GameEvent + +/** Fired when the game reaches stalemate. */ +case class StalemateEvent( + board: Board, + history: GameHistory, + turn: Color +) extends GameEvent + +/** Fired when a move is invalid. */ +case class InvalidMoveEvent( + board: Board, + history: GameHistory, + turn: Color, + reason: String +) extends GameEvent + +/** Fired when the board is reset. */ +case class BoardResetEvent( + board: Board, + history: GameHistory, + turn: Color +) extends GameEvent + +/** Observer trait: implement to receive game state updates. */ +trait Observer: + def onGameEvent(event: GameEvent): Unit + +/** Observable trait: manages observers and notifies them of events. */ +trait Observable: + private val observers = scala.collection.mutable.Set[Observer]() + + /** Register an observer to receive game events. */ + def subscribe(observer: Observer): Unit = + observers += observer + + /** Unregister an observer. */ + def unsubscribe(observer: Observer): Unit = + observers -= observer + + /** Notify all observers of a game event. */ + protected def notifyObservers(event: GameEvent): Unit = + observers.foreach(_.onGameEvent(event)) + + /** Return current list of observers (for testing). */ + def observerCount: Int = observers.size diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala new file mode 100644 index 0000000..df3714c --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala @@ -0,0 +1,113 @@ +package de.nowchess.chess.command + +import de.nowchess.api.board.{Square, File, Rank} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class CommandInvokerTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + + test("CommandInvoker executes a command and adds it to history"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) shouldBe true + invoker.history.size shouldBe 1 + invoker.getCurrentIndex shouldBe 0 + + test("CommandInvoker executes multiple commands in sequence"): + val invoker = new CommandInvoker() + val cmd1 = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = MoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + invoker.execute(cmd1) shouldBe true + invoker.execute(cmd2) shouldBe true + invoker.history.size shouldBe 2 + invoker.getCurrentIndex shouldBe 1 + + test("CommandInvoker.canUndo returns false when empty"): + val invoker = new CommandInvoker() + invoker.canUndo shouldBe false + + test("CommandInvoker.canUndo returns true after execution"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) + invoker.canUndo shouldBe true + + test("CommandInvoker.undo decrements current index"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) + invoker.getCurrentIndex shouldBe 0 + invoker.undo() shouldBe true + invoker.getCurrentIndex shouldBe -1 + + test("CommandInvoker.canRedo returns true after undo"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) + invoker.undo() + invoker.canRedo shouldBe true + + test("CommandInvoker.redo re-executes a command"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) + invoker.undo() shouldBe true + invoker.redo() shouldBe true + invoker.getCurrentIndex shouldBe 0 + + test("CommandInvoker.canUndo returns false when at beginning"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) + invoker.undo() + invoker.canUndo shouldBe false + + test("CommandInvoker clear removes all history"): + val invoker = new CommandInvoker() + val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + invoker.execute(cmd) + invoker.clear() + invoker.history.size shouldBe 0 + invoker.getCurrentIndex shouldBe -1 + + test("CommandInvoker discards all history when executing after undoing all"): + val invoker = new CommandInvoker() + val cmd1 = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = MoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = MoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + invoker.execute(cmd1) + invoker.execute(cmd2) + invoker.undo() + invoker.undo() + // After undoing twice, we're at the beginning (before any commands) + invoker.getCurrentIndex shouldBe -1 + invoker.canRedo shouldBe true + // Executing a new command from the beginning discards all redo history + invoker.execute(cmd3) + invoker.canRedo shouldBe false + invoker.history.size shouldBe 1 + invoker.history(0) shouldBe cmd3 + invoker.getCurrentIndex shouldBe 0 + + test("CommandInvoker discards redo history when executing mid-history"): + val invoker = new CommandInvoker() + val cmd1 = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = MoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = MoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + invoker.execute(cmd1) + invoker.execute(cmd2) + invoker.undo() + // After one undo, we're at the end of cmd1 + invoker.getCurrentIndex shouldBe 0 + invoker.canRedo shouldBe true + // Executing a new command discards cmd2 (the redo history) + invoker.execute(cmd3) + invoker.canRedo shouldBe false + invoker.history.size shouldBe 2 + invoker.history(0) shouldBe cmd1 + invoker.history(1) shouldBe cmd3 + invoker.getCurrentIndex shouldBe 1 + +end CommandInvokerTest diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 8124005..f5493b0 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -6,17 +6,12 @@ import de.nowchess.chess.logic.{CastleSide, GameHistory} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -import java.io.ByteArrayInputStream - class GameControllerTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = GameController.processMove(board, history, turn, raw) - private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = - GameController.gameLoop(board, history, turn) - private def castlingRights(history: GameHistory, color: Color): CastlingRights = de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color) @@ -69,59 +64,6 @@ class GameControllerTest extends AnyFunSuite with Matchers: newTurn shouldBe Color.Black case other => fail(s"Expected Moved, got $other") - // ──── gameLoop ─────────────────────────────────────────────────────── - - private def withInput(input: String)(block: => Unit): Unit = - val stream = ByteArrayInputStream(input.getBytes("UTF-8")) - scala.Console.withIn(stream)(block) - - test("gameLoop: 'quit' exits cleanly without exception"): - withInput("quit\n"): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: EOF (null readLine) exits via quit fallback"): - withInput(""): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: invalid format prints message and recurses until quit"): - withInput("badmove\nquit\n"): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: NoPiece prints message and recurses until quit"): - // E3 is empty in the initial position - withInput("e3e4\nquit\n"): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: WrongColor prints message and recurses until quit"): - // E7 has a Black pawn; it is White's turn - withInput("e7e6\nquit\n"): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: IllegalMove prints message and recurses until quit"): - withInput("e2e5\nquit\n"): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: legal non-capture move recurses with new board then quits"): - withInput("e2e4\nquit\n"): - gameLoop(Board.initial, GameHistory.empty, Color.White) - - test("gameLoop: capture move prints capture message then recurses and quits"): - val captureBoard = Board(Map( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R6) -> Piece.BlackPawn, - sq(File.H, Rank.R1) -> Piece.BlackKing, - sq(File.H, Rank.R8) -> Piece.WhiteKing - )) - withInput("e5d6\nquit\n"): - gameLoop(captureBoard, GameHistory.empty, Color.White) - - // ──── helpers ──────────────────────────────────────────────────────── - - private def captureOutput(block: => Unit): String = - val out = java.io.ByteArrayOutputStream() - scala.Console.withOut(out)(block) - out.toString("UTF-8") - // ──── processMove: check / checkmate / stalemate ───────────────────── test("processMove: legal move that delivers check returns MovedInCheck"): @@ -161,56 +103,6 @@ class GameControllerTest extends AnyFunSuite with Matchers: case MoveResult.Stalemate => succeed case other => fail(s"Expected Stalemate, got $other") - // ──── gameLoop: check / checkmate / stalemate ───────────────────────── - - test("gameLoop: checkmate prints winner message and resets to new game"): - // After Qa1-Qh8, position is checkmate; second "quit" exits the new game - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteQueen, - sq(File.A, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("a1h8\nquit\n"): - gameLoop(b, GameHistory.empty, Color.White) - output should include("Checkmate! White wins.") - - test("gameLoop: stalemate prints draw message and resets to new game"): - val b = Board(Map( - sq(File.B, Rank.R1) -> Piece.WhiteQueen, - sq(File.C, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("b1b6\nquit\n"): - gameLoop(b, GameHistory.empty, Color.White) - output should include("Stalemate! The game is a draw.") - - test("gameLoop: MovedInCheck without capture prints check message"): - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.C, Rank.R3) -> Piece.WhiteKing, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("a1a8\nquit\n"): - gameLoop(b, GameHistory.empty, Color.White) - output should include("Black is in check!") - - test("gameLoop: MovedInCheck with capture prints both capture and check message"): - // White Rook A1 captures Black Pawn on A8, Ra8 then attacks rank 8 putting Kh8 in check - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.C, Rank.R3) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackPawn, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - val output = captureOutput: - withInput("a1a8\nquit\n"): - gameLoop(b, GameHistory.empty, Color.White) - output should include("captures") - output should include("Black is in check!") - // ──── castling execution ───────────────────────────────────────────── test("processMove: e1g1 returns Moved with king on g1 and rook on f1"): diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala new file mode 100644 index 0000000..4798b28 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala @@ -0,0 +1,105 @@ +package de.nowchess.chess.engine + +import scala.collection.mutable +import de.nowchess.api.board.{Board, Color} +import de.nowchess.chess.logic.GameHistory +import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GameEngineTest extends AnyFunSuite with Matchers: + + test("GameEngine starts with initial board state"): + val engine = new GameEngine() + engine.board shouldBe Board.initial + engine.history shouldBe GameHistory.empty + engine.turn shouldBe Color.White + + test("GameEngine accepts Observer subscription"): + val engine = new GameEngine() + val mockObserver = new MockObserver() + engine.subscribe(mockObserver) + engine.observerCount shouldBe 1 + + test("GameEngine notifies observers on valid move"): + val engine = new GameEngine() + val mockObserver = new MockObserver() + engine.subscribe(mockObserver) + engine.processUserInput("e2e4") + mockObserver.events.size shouldBe 1 + mockObserver.events.head shouldBe a[MoveExecutedEvent] + + test("GameEngine updates state after valid move"): + val engine = new GameEngine() + val initialTurn = engine.turn + engine.processUserInput("e2e4") + engine.turn shouldNot be(initialTurn) + engine.turn shouldBe Color.Black + + test("GameEngine notifies observers on invalid move"): + val engine = new GameEngine() + val mockObserver = new MockObserver() + engine.subscribe(mockObserver) + engine.processUserInput("invalid_move") + mockObserver.events.size shouldBe 1 + + test("GameEngine notifies multiple observers"): + val engine = new GameEngine() + val observer1 = new MockObserver() + val observer2 = new MockObserver() + engine.subscribe(observer1) + engine.subscribe(observer2) + engine.processUserInput("e2e4") + observer1.events.size shouldBe 1 + observer2.events.size shouldBe 1 + + test("GameEngine allows observer unsubscription"): + val engine = new GameEngine() + val mockObserver = new MockObserver() + engine.subscribe(mockObserver) + engine.unsubscribe(mockObserver) + engine.observerCount shouldBe 0 + + test("GameEngine unsubscribed observer receives no events"): + val engine = new GameEngine() + val mockObserver = new MockObserver() + engine.subscribe(mockObserver) + engine.unsubscribe(mockObserver) + engine.processUserInput("e2e4") + mockObserver.events.size shouldBe 0 + + test("GameEngine reset notifies observers and resets state"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + val observer = new MockObserver() + engine.subscribe(observer) + engine.reset() + engine.board shouldBe Board.initial + engine.turn shouldBe Color.White + observer.events.size shouldBe 1 + + test("GameEngine processes sequence of moves"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + engine.processUserInput("e2e4") + engine.processUserInput("e7e5") + observer.events.size shouldBe 2 + engine.turn shouldBe Color.White + + test("GameEngine is thread-safe for synchronized operations"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + val t = new Thread(() => engine.processUserInput("e2e4")) + t.start() + t.join() + observer.events.size shouldBe 1 + + // Mock Observer for testing + private class MockObserver extends Observer: + val events = mutable.ListBuffer[GameEvent]() + override def onGameEvent(event: GameEvent): Unit = + events += event + +end GameEngineTest diff --git a/modules/core/src/test/scala/de/nowchess/chess/main/MainTest.scala b/modules/core/src/test/scala/de/nowchess/chess/main/MainTest.scala deleted file mode 100644 index 22553e8..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/main/MainTest.scala +++ /dev/null @@ -1,12 +0,0 @@ -package de.nowchess.chess.main - -import de.nowchess.chess.Main -import java.io.ByteArrayInputStream -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class MainTest extends AnyFunSuite with Matchers: - - test("main exits cleanly when 'quit' is entered"): - scala.Console.withIn(ByteArrayInputStream("quit\n".getBytes("UTF-8"))): - Main.main(Array.empty) diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts new file mode 100644 index 0000000..f70d8c1 --- /dev/null +++ b/modules/ui/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + id("scala") + id("org.scoverage") version "8.1" + application +} + +group = "de.nowchess" +version = "1.0-SNAPSHOT" + +@Suppress("UNCHECKED_CAST") +val versions = rootProject.extra["VERSIONS"] as Map + +repositories { + mavenCentral() +} + +scala { + scalaVersion = versions["SCALA3"]!! +} + +scoverage { + scoverageVersion.set(versions["SCOVERAGE"]!!) +} + +application { + mainClass.set("de.nowchess.ui.Main") +} + +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` +} + +dependencies { + + implementation("org.scala-lang:scala3-compiler_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + implementation("org.scala-lang:scala3-library_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + + implementation(project(":modules:core")) + implementation(project(":modules:api")) + + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest") + testLogging { + events("passed", "skipped", "failed") + } + } + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala new file mode 100644 index 0000000..eb111a0 --- /dev/null +++ b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala @@ -0,0 +1,16 @@ +package de.nowchess.ui + +import de.nowchess.chess.engine.GameEngine +import de.nowchess.ui.terminal.TerminalUI + +/** Application entry point - starts the Terminal UI for the chess game. */ +object Main: + def main(args: Array[String]): Unit = + // Create the core game engine (single source of truth) + val engine = new GameEngine() + + // Create and start the terminal UI + val tui = new TerminalUI(engine) + tui.start() + +end Main diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala new file mode 100644 index 0000000..04f1a6d --- /dev/null +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -0,0 +1,72 @@ +package de.nowchess.ui.terminal + +import scala.io.StdIn +import de.nowchess.chess.engine.GameEngine +import de.nowchess.chess.observer.{Observer, GameEvent, *} +import de.nowchess.chess.view.Renderer + +/** Terminal UI that implements Observer pattern. + * Subscribes to GameEngine and receives state change events. + * Handles all I/O and user interaction in the terminal. + */ +class TerminalUI(engine: GameEngine) extends Observer: + private var running = true + + /** Called by GameEngine whenever a game event occurs. */ + override def onGameEvent(event: GameEvent): Unit = + event match + case e: MoveExecutedEvent => + println() + print(Renderer.render(e.board)) + e.capturedPiece.foreach: cap => + println(s"Captured: $cap on ${e.toSquare}") + println(s"${e.turn.label}'s turn. Enter move (or 'quit'/'q' to exit):") + + case e: CheckDetectedEvent => + println(s"${e.turn.label} is in check!") + + case e: CheckmateEvent => + println(s"Checkmate! ${e.winner.label} wins.") + println() + print(Renderer.render(e.board)) + + case e: StalemateEvent => + println("Stalemate! The game is a draw.") + println() + print(Renderer.render(e.board)) + + case e: InvalidMoveEvent => + println(s"Invalid move: ${e.reason}") + + case e: BoardResetEvent => + println("Board has been reset to initial position.") + println() + print(Renderer.render(e.board)) + println(s"${e.turn.label}'s turn. Enter move (or 'quit'/'q' to exit):") + + /** Start the terminal UI game loop. */ + def start(): Unit = + // Register as observer + engine.subscribe(this) + + // Show initial board + println() + print(Renderer.render(engine.board)) + println(s"${engine.turn.label}'s turn. Enter move (or 'quit'/'q' to exit):") + + // Game loop + while running do + val input = Option(StdIn.readLine()).getOrElse("quit").trim + input.toLowerCase match + case "quit" | "q" => + running = false + println("Game over. Goodbye!") + case "" => + println("Please enter a valid move.") + case _ => + engine.processUserInput(input) + + // Unsubscribe when done + engine.unsubscribe(this) + +end TerminalUI diff --git a/settings.gradle.kts b/settings.gradle.kts index 4259047..f164a80 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ rootProject.name = "NowChessSystems" -include("modules:core", "modules:api") \ No newline at end of file +include("modules:core", "modules:api", "modules:ui") \ No newline at end of file