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:
@@ -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,24 +133,33 @@ 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()
|
||||
|
||||
if ctx.moves.isEmpty then
|
||||
currentContext = ctx
|
||||
notifyObservers(PgnLoadedEvent(currentContext))
|
||||
Right(())
|
||||
else
|
||||
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}")
|
||||
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) =>
|
||||
@@ -160,6 +170,11 @@ class GameEngine(
|
||||
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. */
|
||||
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||
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
|
||||
Reference in New Issue
Block a user