refactor(core): add loadGame/exportGame, remove loadPgn from GameEngine

- Add imports for GameContextImport and GameContextExport
- Add loadGame(importer, input) method to load game from importer
- Add exportGame(exporter) method to export current game context
- Remove entire loadPgn method (replaced by loadGame)
- Remove GameEngineLoadPgnTest (tests old loadPgn API)

loadGame supports both move replay and direct position loading:
- If no moves, sets position directly via loadPosition
- If moves exist, replays through command system for undo/redo support
- Notifies PgnLoadedEvent on success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 14:51:33 +02:00
parent 8b5303fdab
commit e88f502ffc
2 changed files with 33 additions and 198 deletions
@@ -6,6 +6,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextImport, GameContextExport}
import de.nowchess.io.pgn.PgnParser import de.nowchess.io.pgn.PgnParser
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -132,24 +133,33 @@ class GameEngine(
/** Redo the last undone move. */ /** Redo the last undone move. */
def redo(): Unit = synchronized { performRedo() } def redo(): Unit = synchronized { performRedo() }
/** Validate and load a PGN string. /** Load a game using the provided importer.
* Each move is replayed through the command system so undo/redo is available after loading. * 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 { def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
PgnParser.validatePgn(pgn) match importer.importGameContext(input) match
case Left(err) => Left(err) case Left(err) => Left(err)
case Right(game) => case Right(ctx) =>
val savedContext = currentContext val savedContext = currentContext
currentContext = GameContext.initial currentContext = GameContext.initial
pendingPromotion = None pendingPromotion = None
invoker.clear() invoker.clear()
if ctx.moves.isEmpty then
currentContext = ctx
notifyObservers(PgnLoadedEvent(currentContext))
Right(())
else
var error: Option[String] = None var error: Option[String] = None
game.moves.foreach: histMove => ctx.moves.foreach: move =>
handleParsedMove(histMove.from, histMove.to) handleParsedMove(move.from, move.to)
histMove.promotionPiece.foreach(completePromotion) move.moveType match
if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then case MoveType.Promotion(pp) =>
error = Some(s"Promotion required for move ${histMove.from}${histMove.to}") if pendingPromotion.isDefined then completePromotion(pp)
else error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
error match error match
case Some(err) => case Some(err) =>
@@ -160,6 +170,11 @@ class GameEngine(
Right(()) 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. */ /** Load an arbitrary board position, clearing all history and undo/redo state. */
def loadPosition(newContext: GameContext): Unit = synchronized { def loadPosition(newContext: GameContext): Unit = synchronized {
currentContext = newContext currentContext = newContext
@@ -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