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 index d8c5d98..dfbdd96 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.command -import de.nowchess.api.board.Square +import de.nowchess.api.board.{Square, Board, Color, Piece} +import de.nowchess.chess.logic.GameHistory /** Marker trait for all commands that can be executed and undone. * Commands encapsulate user actions and game state transitions. @@ -15,12 +16,33 @@ trait Command: /** 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 +/** Command to move a piece from one square to another. + * Stores the move result so undo can restore previous state. + */ +case class MoveCommand( + from: Square, + to: Square, + var moveResult: Option[MoveResult] = None, + var previousBoard: Option[Board] = None, + var previousHistory: Option[GameHistory] = None, + var previousTurn: Option[Color] = None +) extends Command: + + override def execute(): Boolean = + moveResult.isDefined + + override def undo(): Boolean = + previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined + override def description: String = s"Move from $from to $to" +// Sealed hierarchy of move outcomes (for tracking state changes) +sealed trait MoveResult +object MoveResult: + case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult + case object InvalidFormat extends MoveResult + case object InvalidMove extends MoveResult + /** Command to quit the game. */ case class QuitCommand() extends Command: override def execute(): Boolean = true @@ -28,7 +50,15 @@ case class QuitCommand() extends Command: override def description: String = "Quit game" /** Command to reset the board to initial position. */ -case class ResetCommand() extends Command: +case class ResetCommand( + var previousBoard: Option[Board] = None, + var previousHistory: Option[GameHistory] = None, + var previousTurn: Option[Color] = None +) extends Command: + override def execute(): Boolean = true - override def undo(): Boolean = true + + override def undo(): Boolean = + previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined + override def description: String = "Reset board" 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 index 4f0c5b9..1509301 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -1,140 +1,245 @@ package de.nowchess.chess.engine -import de.nowchess.api.board.{Board, Color, Piece} +import de.nowchess.api.board.{Board, Color, Piece, Square} import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus} import de.nowchess.chess.controller.{GameController, Parser, MoveResult} import de.nowchess.chess.observer.* +import de.nowchess.chess.command.{CommandInvoker, MoveCommand} /** 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 + * All user interactions must go through this engine via Commands, 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 + private val invoker = new CommandInvoker() // Synchronized accessors for current state def board: Board = synchronized { currentBoard } def history: GameHistory = synchronized { currentHistory } def turn: Color = synchronized { currentTurn } + /** Check if undo is available. */ + def canUndo: Boolean = synchronized { invoker.canUndo } + + /** Check if redo is available. */ + def canRedo: Boolean = synchronized { invoker.canRedo } + + /** Get the command history for inspection (testing/debugging). */ + def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history } + /** 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 => + val trimmed = rawInput.trim.toLowerCase + trimmed match + case "quit" | "q" => // Client should handle quit logic; we just return () - case MoveResult.InvalidFormat(raw) => + case "undo" => + performUndo() + + case "redo" => + performRedo() + + case "" => val event = InvalidMoveEvent( currentBoard, currentHistory, currentTurn, - s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4." + "Please enter a valid move or command." ) notifyObservers(event) - case MoveResult.NoPiece => - val event = InvalidMoveEvent( - currentBoard, - currentHistory, - currentTurn, - s"No piece on that square." - ) - notifyObservers(event) + case moveInput => + // Try to parse as a move + Parser.parseMove(moveInput) match + case None => + val event = InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." + ) + notifyObservers(event) - case MoveResult.WrongColor => - val event = InvalidMoveEvent( - currentBoard, - currentHistory, - currentTurn, - "That is not your piece." - ) - notifyObservers(event) + case Some((from, to)) => + // Create a move command with current state snapshot + val cmd = MoveCommand( + from = from, + to = to, + previousBoard = Some(currentBoard), + previousHistory = Some(currentHistory), + previousTurn = Some(currentTurn) + ) - case MoveResult.IllegalMove => - val event = InvalidMoveEvent( - currentBoard, - currentHistory, - currentTurn, - "Illegal move." - ) - notifyObservers(event) + // Execute the move through GameController + GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match + case MoveResult.Quit => + // Should not happen via processUserInput, but handle it + () - 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.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove => + // Move failed, don't add to history + handleFailedMove(moveInput) - 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.Moved(newBoard, newHistory, captured, newTurn) => + // Move succeeded - store result and execute through invoker + cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)) + invoker.execute(cmd) + updateGameState(newBoard, newHistory, newTurn) + emitMoveEvent(from.toString, to.toString, captured, newTurn) - case MoveResult.Checkmate(winner) => - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(CheckmateEvent( - currentBoard, - currentHistory, - currentTurn, - winner - )) + case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => + // Move succeeded with check + cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)) + invoker.execute(cmd) + updateGameState(newBoard, newHistory, newTurn) + emitMoveEvent(from.toString, to.toString, captured, newTurn) + notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) - case MoveResult.Stalemate => - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(StalemateEvent( - currentBoard, - currentHistory, - currentTurn - )) + case MoveResult.Checkmate(winner) => + // Move resulted in checkmate + cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) + invoker.execute(cmd) + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) + + case MoveResult.Stalemate => + // Move resulted in stalemate + cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) + invoker.execute(cmd) + 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. - */ + /** Undo the last move. */ + def undo(): Unit = synchronized { + performUndo() + } + + /** Redo the last undone move. */ + def redo(): Unit = synchronized { + performRedo() + } + + /** Reset the board to initial position. */ def reset(): Unit = synchronized { currentBoard = Board.initial currentHistory = GameHistory.empty currentTurn = Color.White + invoker.clear() notifyObservers(BoardResetEvent( currentBoard, currentHistory, currentTurn )) } + + // ──── Private Helpers ──── + + private def performUndo(): Unit = + if invoker.canUndo then + val history = invoker.history + val currentIdx = invoker.getCurrentIndex + if currentIdx >= 0 && currentIdx < history.size then + val cmd = history(currentIdx) + cmd match + case moveCmd: MoveCommand => + if moveCmd.undo() then + moveCmd.previousBoard.foreach(currentBoard = _) + moveCmd.previousHistory.foreach(currentHistory = _) + moveCmd.previousTurn.foreach(currentTurn = _) + invoker.undo() + notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn)) + else + notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Cannot undo this move.")) + case _ => + // Other command types - just revert the invoker + invoker.undo() + notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn)) + else + notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo.")) + + private def performRedo(): Unit = + if invoker.canRedo then + val history = invoker.history + val nextIdx = invoker.getCurrentIndex + 1 + if nextIdx >= 0 && nextIdx < history.size then + val cmd = history(nextIdx) + cmd match + case moveCmd: MoveCommand => + if moveCmd.execute() then + moveCmd.moveResult.foreach { + case de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured) => + updateGameState(newBoard, newHistory, newTurn) + invoker.redo() + emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, captured, newTurn) + case _ => () + } + else + notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Cannot redo this move.")) + case _ => + invoker.redo() + notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn)) + else + notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo.")) + + private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit = + currentBoard = newBoard + currentHistory = newHistory + currentTurn = newTurn + + private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit = + val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}") + notifyObservers(MoveExecutedEvent( + currentBoard, + currentHistory, + newTurn, + fromSq, + toSq, + capturedDesc + )) + + private def handleFailedMove(moveInput: String): Unit = + GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match + case MoveResult.InvalidFormat(raw) => + notifyObservers(InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4." + )) + case MoveResult.NoPiece => + notifyObservers(InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + "No piece on that square." + )) + case MoveResult.WrongColor => + notifyObservers(InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + "That is not your piece." + )) + case MoveResult.IllegalMove => + notifyObservers(InvalidMoveEvent( + currentBoard, + currentHistory, + currentTurn, + "Illegal move." + )) + case _ => () + end GameEngine 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 index df3714c..fc38376 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala @@ -1,24 +1,35 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.board.{Square, File, Rank, Board, Color} +import de.nowchess.chess.logic.GameHistory 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) + + private def createMoveCommand(from: Square, to: Square): MoveCommand = + MoveCommand( + from = from, + to = to, + moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), + previousBoard = Some(Board.initial), + previousHistory = Some(GameHistory.empty), + previousTurn = Some(Color.White) + ) 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)) + val cmd = createMoveCommand(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)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(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 @@ -30,13 +41,13 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: test("CommandInvoker.canUndo returns true after execution"): val invoker = new CommandInvoker() - val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(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)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) invoker.getCurrentIndex shouldBe 0 invoker.undo() shouldBe true @@ -44,14 +55,14 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: test("CommandInvoker.canRedo returns true after undo"): val invoker = new CommandInvoker() - val cmd = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(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)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) invoker.undo() shouldBe true invoker.redo() shouldBe true @@ -59,14 +70,14 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: 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)) + val cmd = createMoveCommand(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)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) invoker.clear() invoker.history.size shouldBe 0 @@ -74,9 +85,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: 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)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) invoker.execute(cmd1) invoker.execute(cmd2) invoker.undo() @@ -93,9 +104,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: 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)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) invoker.execute(cmd1) invoker.execute(cmd2) invoker.undo() 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 index 04f1a6d..41c58d6 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -20,7 +20,7 @@ class TerminalUI(engine: GameEngine) extends Observer: 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):") + printPrompt(e.turn) case e: CheckDetectedEvent => println(s"${e.turn.label} is in check!") @@ -36,13 +36,13 @@ class TerminalUI(engine: GameEngine) extends Observer: print(Renderer.render(e.board)) case e: InvalidMoveEvent => - println(s"Invalid move: ${e.reason}") + println(s"⚠️ ${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):") + printPrompt(e.turn) /** Start the terminal UI game loop. */ def start(): Unit = @@ -52,7 +52,7 @@ class TerminalUI(engine: GameEngine) extends Observer: // Show initial board println() print(Renderer.render(engine.board)) - println(s"${engine.turn.label}'s turn. Enter move (or 'quit'/'q' to exit):") + printPrompt(engine.turn) // Game loop while running do @@ -62,11 +62,16 @@ class TerminalUI(engine: GameEngine) extends Observer: running = false println("Game over. Goodbye!") case "" => - println("Please enter a valid move.") + printPrompt(engine.turn) case _ => engine.processUserInput(input) // Unsubscribe when done engine.unsubscribe(this) + private def printPrompt(turn: de.nowchess.api.board.Color): Unit = + val undoHint = if engine.canUndo then " [undo]" else "" + val redoHint = if engine.canRedo then " [redo]" else "" + print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ") + end TerminalUI