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 377e266..58533da 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 @@ -6,6 +6,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.chess.controller.Parser import de.nowchess.chess.observer.* import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} +import de.nowchess.io.{GameContextImport, GameContextExport} import de.nowchess.io.pgn.PgnParser import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules @@ -132,32 +133,46 @@ class GameEngine( /** Redo the last undone move. */ def redo(): Unit = synchronized { performRedo() } - /** Validate and load a PGN string. - * Each move is replayed through the command system so undo/redo is available after loading. + /** Load a game using the provided importer. + * If the imported context has moves, they are replayed through the command system. + * Otherwise, the position is set directly. + * Notifies observers with PgnLoadedEvent on success. */ - def loadPgn(pgn: String): Either[String, Unit] = synchronized { - PgnParser.validatePgn(pgn) match + def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized { + importer.importGameContext(input) match case Left(err) => Left(err) - case Right(game) => + case Right(ctx) => val savedContext = currentContext currentContext = GameContext.initial pendingPromotion = None invoker.clear() - var error: Option[String] = None - game.moves.foreach: histMove => - handleParsedMove(histMove.from, histMove.to) - histMove.promotionPiece.foreach(completePromotion) - if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then - error = Some(s"Promotion required for move ${histMove.from}${histMove.to}") + if ctx.moves.isEmpty then + currentContext = ctx + notifyObservers(PgnLoadedEvent(currentContext)) + Right(()) + else + var error: Option[String] = None + ctx.moves.foreach: move => + handleParsedMove(move.from, move.to) + move.moveType match + case MoveType.Promotion(pp) => + if pendingPromotion.isDefined then completePromotion(pp) + else error = Some(s"Promotion required for move ${move.from}${move.to}") + case _ => () - error match - case Some(err) => - currentContext = savedContext - Left(err) - case None => - notifyObservers(PgnLoadedEvent(currentContext)) - Right(()) + error match + case Some(err) => + currentContext = savedContext + Left(err) + case None => + notifyObservers(PgnLoadedEvent(currentContext)) + Right(()) + } + + /** Export the current game context using the provided exporter. */ + def exportGame(exporter: GameContextExport): String = synchronized { + exporter.exportGameContext(currentContext) } /** Load an arbitrary board position, clearing all history and undo/redo state. */ diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala deleted file mode 100644 index 09623ad..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala +++ /dev/null @@ -1,180 +0,0 @@ -package de.nowchess.chess.engine - -import scala.collection.mutable -import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.observer.* -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: - - private class EventCapture extends Observer: - val events: mutable.Buffer[GameEvent] = mutable.Buffer.empty - def onGameEvent(event: GameEvent): Unit = events += event - def lastEvent: GameEvent = events.last - - test("loadPgn: valid PGN returns Right and updates board/history"): - val engine = new GameEngine() - val pgn = - """[Event "Test"] - -1. e4 e5 -""" - val result = engine.loadPgn(pgn) - result shouldBe Right(()) - engine.context.moves.size shouldBe 2 - engine.turn shouldBe Color.White - - test("loadPgn: emits PgnLoadedEvent on success"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - val pgn = "[Event \"T\"]\n\n1. e4 e5\n" - engine.loadPgn(pgn) - cap.events.last shouldBe a[PgnLoadedEvent] - - test("loadPgn: after load canUndo is true and canRedo is false"): - val engine = new GameEngine() - val pgn = "[Event \"T\"]\n\n1. e4 e5\n" - engine.loadPgn(pgn) shouldBe Right(()) - engine.canUndo shouldBe true - engine.canRedo shouldBe false - - test("loadPgn: undo works after loading PGN"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - val pgn = "[Event \"T\"]\n\n1. e4 e5\n" - engine.loadPgn(pgn) - cap.events.clear() - engine.undo() - cap.events.last shouldBe a[MoveUndoneEvent] - engine.context.moves.size shouldBe 1 - - test("loadPgn: undo then redo restores position after PGN load"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - val pgn = "[Event \"T\"]\n\n1. e4 e5\n" - engine.loadPgn(pgn) - val boardAfterLoad = engine.board - engine.undo() - engine.redo() - cap.events.last shouldBe a[MoveRedoneEvent] - engine.board shouldBe boardAfterLoad - engine.context.moves.size shouldBe 2 - - test("loadPgn: longer game loads all moves into command history"): - val engine = new GameEngine() - val pgn = - """[Event "Ruy Lopez"] - -1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 -""" - engine.loadPgn(pgn) shouldBe Right(()) - engine.context.moves.size shouldBe 6 - engine.commandHistory.length shouldBe 6 - - test("loadPgn: invalid PGN returns Left and does not change state"): - val engine = new GameEngine() - val result = engine.loadPgn("[Event \"T\"]\n\n1. Qd4\n") - result.isLeft shouldBe true - engine.context.moves shouldBe empty - - test("undo emits MoveUndoneEvent with pgnNotation"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - engine.processUserInput("e2e4") - cap.events.clear() - engine.undo() - cap.events.last shouldBe a[MoveUndoneEvent] - val evt = cap.events.last.asInstanceOf[MoveUndoneEvent] - evt.pgnNotation should not be empty - evt.pgnNotation shouldBe "e4" - - test("redo emits MoveRedoneEvent with pgnNotation"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - engine.processUserInput("e2e4") - engine.undo() - cap.events.clear() - engine.redo() - cap.events.last shouldBe a[MoveRedoneEvent] - val evt = cap.events.last.asInstanceOf[MoveRedoneEvent] - evt.pgnNotation should not be empty - evt.pgnNotation shouldBe "e4" - - test("undo emits MoveUndoneEvent with O-O notation for castling"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - engine.processUserInput("e2e4") - engine.processUserInput("e7e5") - engine.processUserInput("g1f3") - engine.processUserInput("b8c6") - engine.processUserInput("f1c4") - engine.processUserInput("f8c5") - engine.processUserInput("e1g1") // white castles kingside - cap.events.clear() - engine.undo() - val evt = cap.events.last.asInstanceOf[MoveUndoneEvent] - evt.pgnNotation shouldBe "O-O" - - test("redo emits MoveRedoneEvent with from/to squares and capturedPiece"): - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - engine.processUserInput("b2b4") - engine.processUserInput("a7a6") - engine.processUserInput("b4b5") - engine.processUserInput("h7h6") - engine.processUserInput("b5a6") // white pawn captures black pawn - engine.undo() - cap.events.clear() - engine.redo() - val evt = cap.events.last.asInstanceOf[MoveRedoneEvent] - evt.fromSquare shouldBe "b5" - evt.toSquare shouldBe "a6" - evt.capturedPiece.isDefined shouldBe true - - test("loadPgn: clears previous game state before loading"): - val engine = new GameEngine() - engine.processUserInput("e2e4") - val pgn = "[Event \"T\"]\n\n1. d4 d5\n" - engine.loadPgn(pgn) shouldBe Right(()) - engine.context.moves.head.to shouldBe de.nowchess.api.board.Square( - de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4 - ) - - test("loadPosition: fires BoardResetEvent and updates context"): - import de.nowchess.api.board.{Board, Color} - import de.nowchess.api.game.GameContext - import de.nowchess.io.fen.FenParser - - val engine = new GameEngine() - val cap = new EventCapture() - engine.subscribe(cap) - - // Make a move so there is history to clear - engine.processUserInput("e2e4") - engine.canUndo shouldBe true - - // Load a custom position - val customBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val customCtx = GameContext.initial.withBoard(customBoard).withTurn(Color.Black) - cap.events.clear() - engine.loadPosition(customCtx) - - // BoardResetEvent fired - cap.events.last shouldBe a[BoardResetEvent] - - // Engine state reflects the loaded position - engine.board shouldBe customBoard - engine.turn shouldBe Color.Black - engine.context.moves shouldBe empty - - // Undo/redo history cleared - engine.canUndo shouldBe false - engine.canRedo shouldBe false