diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 4f4edba..f1d0a36 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -12,6 +12,7 @@
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 8db25d6..a0f8d4f 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
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 dfbdd96..5bc93a3 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
@@ -22,10 +22,10 @@ trait Command:
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
+ moveResult: Option[MoveResult] = None,
+ previousBoard: Option[Board] = None,
+ previousHistory: Option[GameHistory] = None,
+ previousTurn: Option[Color] = None
) extends Command:
override def execute(): Boolean =
@@ -51,9 +51,9 @@ case class QuitCommand() extends Command:
/** Command to reset the board to initial position. */
case class ResetCommand(
- var previousBoard: Option[Board] = None,
- var previousHistory: Option[GameHistory] = None,
- var previousTurn: Option[Color] = None
+ previousBoard: Option[Board] = None,
+ previousHistory: Option[GameHistory] = None,
+ previousTurn: Option[Color] = None
) extends Command:
override def execute(): Boolean = true
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 218becd..ff42aad 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
@@ -85,23 +85,23 @@ class GameEngine extends Observable:
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)
+ val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
+ invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
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)
+ val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
+ invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(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)
+ val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
+ invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
@@ -109,8 +109,8 @@ class GameEngine extends Observable:
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)
+ val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
+ invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala
new file mode 100644
index 0000000..b23350a
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala
@@ -0,0 +1,65 @@
+package de.nowchess.chess.command
+
+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 MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
+
+ private def sq(f: File, r: Rank): Square = Square(f, r)
+
+ test("MoveCommand should be immutable - fields cannot be mutated after creation"):
+ val cmd1 = MoveCommand(
+ from = sq(File.E, Rank.R2),
+ to = sq(File.E, Rank.R4)
+ )
+
+ // Create second command with filled state
+ val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
+ val cmd2 = cmd1.copy(
+ moveResult = Some(result),
+ previousBoard = Some(Board.initial),
+ previousHistory = Some(GameHistory.empty),
+ previousTurn = Some(Color.White)
+ )
+
+ // Original should be unchanged
+ cmd1.moveResult shouldBe None
+ cmd1.previousBoard shouldBe None
+ cmd1.previousHistory shouldBe None
+ cmd1.previousTurn shouldBe None
+
+ // New should have values
+ cmd2.moveResult shouldBe Some(result)
+ cmd2.previousBoard shouldBe Some(Board.initial)
+ cmd2.previousHistory shouldBe Some(GameHistory.empty)
+ cmd2.previousTurn shouldBe Some(Color.White)
+
+ test("MoveCommand equals and hashCode respect immutability"):
+ val cmd1 = MoveCommand(
+ from = sq(File.E, Rank.R2),
+ to = sq(File.E, Rank.R4),
+ moveResult = None,
+ previousBoard = None,
+ previousHistory = None,
+ previousTurn = None
+ )
+
+ val cmd2 = MoveCommand(
+ from = sq(File.E, Rank.R2),
+ to = sq(File.E, Rank.R4),
+ moveResult = None,
+ previousBoard = None,
+ previousHistory = None,
+ previousTurn = None
+ )
+
+ // Same values should be equal
+ cmd1 shouldBe cmd2
+ cmd1.hashCode shouldBe cmd2.hashCode
+
+ // Hash should be consistent (required for use as map keys)
+ val hash1 = cmd1.hashCode
+ val hash2 = cmd1.hashCode
+ hash1 shouldBe hash2
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala
index 3a2076a..6843e11 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala
@@ -206,42 +206,6 @@ class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
canUndo shouldBe false
canRedo shouldBe false
- test("GameEngine performUndo handles moveCmd.undo() returning false"):
- val engine = new GameEngine()
- engine.processUserInput("e2e4")
-
- // Sabotage the command so that undo() returns false
- val cmd = engine.commandHistory.head.asInstanceOf[de.nowchess.chess.command.MoveCommand]
- cmd.previousBoard = None
-
- engine.undo()
- // Undo should do nothing (fall through if statement); turn should still be Black
- engine.turn shouldBe Color.Black
-
- test("GameEngine performRedo handles moveCmd.execute() returning false"):
- val engine = new GameEngine()
- engine.processUserInput("e2e4")
- engine.undo()
-
- // Sabotage the command so that execute() returns false
- val cmd = engine.commandHistory.head.asInstanceOf[de.nowchess.chess.command.MoveCommand]
- cmd.moveResult = None
-
- engine.redo()
- // Should do nothing; turn should remain White
- engine.turn shouldBe Color.White
-
- test("GameEngine performRedo handles non-successful moveResult"):
- val engine = new GameEngine()
- engine.processUserInput("e2e4")
- engine.undo()
-
- val cmd = engine.commandHistory.head.asInstanceOf[de.nowchess.chess.command.MoveCommand]
- cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.InvalidMove)
-
- engine.redo()
- // Should fall into `case _ => ()` branch and not update state
- engine.turn shouldBe Color.White
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
diff --git a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
index 897a729..a0b4dea 100644
--- a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
+++ b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
@@ -46,6 +46,30 @@ class TerminalUITest extends AnyFunSuite with Matchers {
output.split("White's turn.").length should be > 2
}
+ test("TerminalUI should explicitly handle empty input by re-prompting") {
+ val in = new ByteArrayInputStream("\n\nq\n".getBytes)
+ val out = new ByteArrayOutputStream()
+
+ val engine = new GameEngine()
+ val ui = new TerminalUI(engine)
+
+ Console.withIn(in) {
+ Console.withOut(out) {
+ ui.start()
+ }
+ }
+
+ val output = out.toString
+ // With two empty inputs, prompt should appear at least 4 times:
+ // 1. Initial board display
+ // 2. After first empty input
+ // 3. After second empty input
+ // 4. Before quit
+ val promptCount = output.split("White's turn.").length
+ promptCount should be >= 4
+ output should include("Game over. Goodbye!")
+ }
+
test("TerminalUI printPrompt should include undo and redo hints if engine returns true") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()