feat: undo/redo added

This commit is contained in:
shahdlala66
2026-03-29 20:58:33 +02:00
committed by Janis
parent bbe53905c3
commit 1f3a653bcd
4 changed files with 266 additions and 115 deletions
@@ -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"
@@ -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
@@ -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()
@@ -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