feat: undo/redo added
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.command
|
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.
|
/** Marker trait for all commands that can be executed and undone.
|
||||||
* Commands encapsulate user actions and game state transitions.
|
* Commands encapsulate user actions and game state transitions.
|
||||||
@@ -15,12 +16,33 @@ trait Command:
|
|||||||
/** A human-readable description of this command. */
|
/** A human-readable description of this command. */
|
||||||
def description: String
|
def description: String
|
||||||
|
|
||||||
/** Command to move a piece from one square to another. */
|
/** Command to move a piece from one square to another.
|
||||||
case class MoveCommand(from: Square, to: Square) extends Command:
|
* Stores the move result so undo can restore previous state.
|
||||||
override def execute(): Boolean = true
|
*/
|
||||||
override def undo(): Boolean = true
|
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"
|
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. */
|
/** Command to quit the game. */
|
||||||
case class QuitCommand() extends Command:
|
case class QuitCommand() extends Command:
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
@@ -28,7 +50,15 @@ case class QuitCommand() extends Command:
|
|||||||
override def description: String = "Quit game"
|
override def description: String = "Quit game"
|
||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** 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 execute(): Boolean = true
|
||||||
override def undo(): Boolean = true
|
|
||||||
|
override def undo(): Boolean =
|
||||||
|
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
||||||
|
|
||||||
override def description: String = "Reset board"
|
override def description: String = "Reset board"
|
||||||
|
|||||||
@@ -1,140 +1,245 @@
|
|||||||
package de.nowchess.chess.engine
|
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.logic.{GameHistory, GameRules, PositionStatus}
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||||
import de.nowchess.chess.observer.*
|
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.
|
/** 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.
|
* 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.
|
* are communicated to observers via GameEvent notifications.
|
||||||
*/
|
*/
|
||||||
class GameEngine extends Observable:
|
class GameEngine extends Observable:
|
||||||
private var currentBoard: Board = Board.initial
|
private var currentBoard: Board = Board.initial
|
||||||
private var currentHistory: GameHistory = GameHistory.empty
|
private var currentHistory: GameHistory = GameHistory.empty
|
||||||
private var currentTurn: Color = Color.White
|
private var currentTurn: Color = Color.White
|
||||||
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized { currentBoard }
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def history: GameHistory = synchronized { currentHistory }
|
||||||
def turn: Color = synchronized { currentTurn }
|
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.
|
/** Process a raw move input string and update game state if valid.
|
||||||
* Notifies all observers of the outcome via GameEvent.
|
* Notifies all observers of the outcome via GameEvent.
|
||||||
*/
|
*/
|
||||||
def processUserInput(rawInput: String): Unit = synchronized {
|
def processUserInput(rawInput: String): Unit = synchronized {
|
||||||
GameController.processMove(currentBoard, currentHistory, currentTurn, rawInput) match
|
val trimmed = rawInput.trim.toLowerCase
|
||||||
case MoveResult.Quit =>
|
trimmed match
|
||||||
|
case "quit" | "q" =>
|
||||||
// Client should handle quit logic; we just return
|
// Client should handle quit logic; we just return
|
||||||
()
|
()
|
||||||
|
|
||||||
case MoveResult.InvalidFormat(raw) =>
|
case "undo" =>
|
||||||
|
performUndo()
|
||||||
|
|
||||||
|
case "redo" =>
|
||||||
|
performRedo()
|
||||||
|
|
||||||
|
case "" =>
|
||||||
val event = InvalidMoveEvent(
|
val event = InvalidMoveEvent(
|
||||||
currentBoard,
|
currentBoard,
|
||||||
currentHistory,
|
currentHistory,
|
||||||
currentTurn,
|
currentTurn,
|
||||||
s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4."
|
"Please enter a valid move or command."
|
||||||
)
|
)
|
||||||
notifyObservers(event)
|
notifyObservers(event)
|
||||||
|
|
||||||
case MoveResult.NoPiece =>
|
case moveInput =>
|
||||||
val event = InvalidMoveEvent(
|
// Try to parse as a move
|
||||||
currentBoard,
|
Parser.parseMove(moveInput) match
|
||||||
currentHistory,
|
case None =>
|
||||||
currentTurn,
|
val event = InvalidMoveEvent(
|
||||||
s"No piece on that square."
|
currentBoard,
|
||||||
)
|
currentHistory,
|
||||||
notifyObservers(event)
|
currentTurn,
|
||||||
|
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||||
|
)
|
||||||
|
notifyObservers(event)
|
||||||
|
|
||||||
case MoveResult.WrongColor =>
|
case Some((from, to)) =>
|
||||||
val event = InvalidMoveEvent(
|
// Create a move command with current state snapshot
|
||||||
currentBoard,
|
val cmd = MoveCommand(
|
||||||
currentHistory,
|
from = from,
|
||||||
currentTurn,
|
to = to,
|
||||||
"That is not your piece."
|
previousBoard = Some(currentBoard),
|
||||||
)
|
previousHistory = Some(currentHistory),
|
||||||
notifyObservers(event)
|
previousTurn = Some(currentTurn)
|
||||||
|
)
|
||||||
|
|
||||||
case MoveResult.IllegalMove =>
|
// Execute the move through GameController
|
||||||
val event = InvalidMoveEvent(
|
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||||
currentBoard,
|
case MoveResult.Quit =>
|
||||||
currentHistory,
|
// Should not happen via processUserInput, but handle it
|
||||||
currentTurn,
|
()
|
||||||
"Illegal move."
|
|
||||||
)
|
|
||||||
notifyObservers(event)
|
|
||||||
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove =>
|
||||||
currentBoard = newBoard
|
// Move failed, don't add to history
|
||||||
currentHistory = newHistory
|
handleFailedMove(moveInput)
|
||||||
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) =>
|
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||||
currentBoard = newBoard
|
// Move succeeded - store result and execute through invoker
|
||||||
currentHistory = newHistory
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))
|
||||||
currentTurn = newTurn
|
invoker.execute(cmd)
|
||||||
val fromSq = Parser.parseMove(rawInput.trim).map(_._1).fold("?")(_.toString)
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
val toSq = Parser.parseMove(rawInput.trim).map(_._2).fold("?")(_.toString)
|
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||||
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) =>
|
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||||
currentBoard = Board.initial
|
// Move succeeded with check
|
||||||
currentHistory = GameHistory.empty
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))
|
||||||
currentTurn = Color.White
|
invoker.execute(cmd)
|
||||||
notifyObservers(CheckmateEvent(
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
currentBoard,
|
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||||
currentHistory,
|
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||||
currentTurn,
|
|
||||||
winner
|
|
||||||
))
|
|
||||||
|
|
||||||
case MoveResult.Stalemate =>
|
case MoveResult.Checkmate(winner) =>
|
||||||
currentBoard = Board.initial
|
// Move resulted in checkmate
|
||||||
currentHistory = GameHistory.empty
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
|
||||||
currentTurn = Color.White
|
invoker.execute(cmd)
|
||||||
notifyObservers(StalemateEvent(
|
currentBoard = Board.initial
|
||||||
currentBoard,
|
currentHistory = GameHistory.empty
|
||||||
currentHistory,
|
currentTurn = Color.White
|
||||||
currentTurn
|
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.
|
/** Undo the last move. */
|
||||||
* Notifies all observers of the reset.
|
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 {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentBoard = Board.initial
|
||||||
currentHistory = GameHistory.empty
|
currentHistory = GameHistory.empty
|
||||||
currentTurn = Color.White
|
currentTurn = Color.White
|
||||||
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(
|
notifyObservers(BoardResetEvent(
|
||||||
currentBoard,
|
currentBoard,
|
||||||
currentHistory,
|
currentHistory,
|
||||||
currentTurn
|
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
|
end GameEngine
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
package de.nowchess.chess.command
|
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.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
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"):
|
test("CommandInvoker executes a command and adds it to history"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
|
|
||||||
test("CommandInvoker executes multiple commands in sequence"):
|
test("CommandInvoker executes multiple commands in sequence"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = MoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
invoker.execute(cmd1) shouldBe true
|
invoker.execute(cmd1) shouldBe true
|
||||||
invoker.execute(cmd2) shouldBe true
|
invoker.execute(cmd2) shouldBe true
|
||||||
invoker.history.size shouldBe 2
|
invoker.history.size shouldBe 2
|
||||||
@@ -30,13 +41,13 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("CommandInvoker.canUndo returns true after execution"):
|
test("CommandInvoker.canUndo returns true after execution"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd)
|
||||||
invoker.canUndo shouldBe true
|
invoker.canUndo shouldBe true
|
||||||
|
|
||||||
test("CommandInvoker.undo decrements current index"):
|
test("CommandInvoker.undo decrements current index"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd)
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
@@ -44,14 +55,14 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("CommandInvoker.canRedo returns true after undo"):
|
test("CommandInvoker.canRedo returns true after undo"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.canRedo shouldBe true
|
invoker.canRedo shouldBe true
|
||||||
|
|
||||||
test("CommandInvoker.redo re-executes a command"):
|
test("CommandInvoker.redo re-executes a command"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd)
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.redo() shouldBe true
|
invoker.redo() shouldBe true
|
||||||
@@ -59,14 +70,14 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("CommandInvoker.canUndo returns false when at beginning"):
|
test("CommandInvoker.canUndo returns false when at beginning"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
invoker.canUndo shouldBe false
|
invoker.canUndo shouldBe false
|
||||||
|
|
||||||
test("CommandInvoker clear removes all history"):
|
test("CommandInvoker clear removes all history"):
|
||||||
val invoker = new CommandInvoker()
|
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.execute(cmd)
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
invoker.history.size shouldBe 0
|
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"):
|
test("CommandInvoker discards all history when executing after undoing all"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = MoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val cmd3 = MoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
@@ -93,9 +104,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("CommandInvoker discards redo history when executing mid-history"):
|
test("CommandInvoker discards redo history when executing mid-history"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = MoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val cmd2 = MoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
val cmd3 = MoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
print(Renderer.render(e.board))
|
print(Renderer.render(e.board))
|
||||||
e.capturedPiece.foreach: cap =>
|
e.capturedPiece.foreach: cap =>
|
||||||
println(s"Captured: $cap on ${e.toSquare}")
|
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 =>
|
case e: CheckDetectedEvent =>
|
||||||
println(s"${e.turn.label} is in check!")
|
println(s"${e.turn.label} is in check!")
|
||||||
@@ -36,13 +36,13 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
print(Renderer.render(e.board))
|
print(Renderer.render(e.board))
|
||||||
|
|
||||||
case e: InvalidMoveEvent =>
|
case e: InvalidMoveEvent =>
|
||||||
println(s"Invalid move: ${e.reason}")
|
println(s"⚠️ ${e.reason}")
|
||||||
|
|
||||||
case e: BoardResetEvent =>
|
case e: BoardResetEvent =>
|
||||||
println("Board has been reset to initial position.")
|
println("Board has been reset to initial position.")
|
||||||
println()
|
println()
|
||||||
print(Renderer.render(e.board))
|
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. */
|
/** Start the terminal UI game loop. */
|
||||||
def start(): Unit =
|
def start(): Unit =
|
||||||
@@ -52,7 +52,7 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
// Show initial board
|
// Show initial board
|
||||||
println()
|
println()
|
||||||
print(Renderer.render(engine.board))
|
print(Renderer.render(engine.board))
|
||||||
println(s"${engine.turn.label}'s turn. Enter move (or 'quit'/'q' to exit):")
|
printPrompt(engine.turn)
|
||||||
|
|
||||||
// Game loop
|
// Game loop
|
||||||
while running do
|
while running do
|
||||||
@@ -62,11 +62,16 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
running = false
|
running = false
|
||||||
println("Game over. Goodbye!")
|
println("Game over. Goodbye!")
|
||||||
case "" =>
|
case "" =>
|
||||||
println("Please enter a valid move.")
|
printPrompt(engine.turn)
|
||||||
case _ =>
|
case _ =>
|
||||||
engine.processUserInput(input)
|
engine.processUserInput(input)
|
||||||
|
|
||||||
// Unsubscribe when done
|
// Unsubscribe when done
|
||||||
engine.unsubscribe(this)
|
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
|
end TerminalUI
|
||||||
|
|||||||
Reference in New Issue
Block a user