From 41c05a08e8983de5108166e7c9ef38cc2e7e4b4b Mon Sep 17 00:00:00 2001 From: Janis Date: Mon, 6 Apr 2026 09:03:50 +0200 Subject: [PATCH] docs: moved plans and specs to correct repo --- .../plans/2026-04-03-ncs22-module-refactor.md | 364 -------- .../plans/2026-04-05-io-interface-refactor.md | 780 ------------------ ...e-refactor-interface-abstraction-design.md | 212 ----- ...2026-04-05-io-interface-refactor-design.md | 91 -- 4 files changed, 1447 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-03-ncs22-module-refactor.md delete mode 100644 docs/superpowers/plans/2026-04-05-io-interface-refactor.md delete mode 100644 docs/superpowers/specs/2026-04-03-module-refactor-interface-abstraction-design.md delete mode 100644 docs/superpowers/specs/2026-04-05-io-interface-refactor-design.md diff --git a/docs/superpowers/plans/2026-04-03-ncs22-module-refactor.md b/docs/superpowers/plans/2026-04-03-ncs22-module-refactor.md deleted file mode 100644 index faed089..0000000 --- a/docs/superpowers/plans/2026-04-03-ncs22-module-refactor.md +++ /dev/null @@ -1,364 +0,0 @@ -# NCS-22: Module Refactoring with Interface Abstraction - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. - -**Goal:** Split `modules/core` into clean layers (api → rule → core) with RuleSet as single source of truth for chess rules. - -**Architecture:** Three-layer model with immutable GameContext bundling all game state. RuleSet interface abstracts all rule decisions. GameEngine calls RuleSet directly; GameController removed. - -**Tech Stack:** Scala 3, Gradle, scoverage (100% coverage required) - ---- - -### Task 1: Create GameContext immutable type in modules/api - -**Files:** -- Create: `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` - -**Dependency:** modules/api depends only on itself (no other modules) - -- [ ] **Step 1: Write GameContext case class with all game state** - -```scala -package de.nowchess.api.game - -import de.nowchess.api.board.{Board, Color, Square} -import de.nowchess.api.move.Move - -/** Immutable bundle of complete game state. - * All state changes produce new GameContext instances. - */ -case class GameContext( - board: Board, - turn: Color, - castlingRights: CastlingRights, - enPassantSquare: Option[Square], - halfMoveClock: Int, - moves: List[Move] -): - /** Create new context with updated board. */ - def withBoard(newBoard: Board): GameContext = copy(board = newBoard) - - /** Create new context with updated turn. */ - def withTurn(newTurn: Color): GameContext = copy(turn = newTurn) - - /** Create new context with updated castling rights. */ - def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights) - - /** Create new context with updated en passant square. */ - def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq) - - /** Create new context with updated half-move clock. */ - def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock) - - /** Create new context with move appended to history. */ - def withMove(move: Move): GameContext = copy(moves = moves :+ move) - -object GameContext: - /** Initial position: white to move, all castling rights, no en passant. */ - def initial: GameContext = GameContext( - board = Board.initial, - turn = Color.White, - castlingRights = CastlingRights.initial, - enPassantSquare = None, - halfMoveClock = 0, - moves = List.empty - ) -``` - -- [ ] **Step 2: Create CastlingRights type in modules/api** - -Create `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala`: - -```scala -package de.nowchess.api.board - -case class CastlingRights( - whiteKingSide: Boolean, - whiteQueenSide: Boolean, - blackKingSide: Boolean, - blackQueenSide: Boolean -): - def removeWhiteKingSide: CastlingRights = copy(whiteKingSide = false) - def removeWhiteQueenSide: CastlingRights = copy(whiteQueenSide = false) - def removeBlackKingSide: CastlingRights = copy(blackKingSide = false) - def removeBlackQueenSide: CastlingRights = copy(blackQueenSide = false) - -object CastlingRights: - def initial: CastlingRights = CastlingRights(true, true, true, true) -``` - -- [ ] **Step 3: Verify GameContext compiles** - -Run: `./gradlew :modules:api:compileScala` - -Expected: SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala -git add modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala -git commit -m "feat(api): add immutable GameContext type" -``` - ---- - -### Task 2: Refactor RuleSet interface in modules/rule - -**Files:** -- Modify: `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` - -- [ ] **Step 1: Replace RuleSet with GameContext-based interface** - -```scala -package de.nowchess.rules - -import de.nowchess.api.game.GameContext -import de.nowchess.api.board.Square -import de.nowchess.api.move.Move - -/** Extension point for chess rule variants (standard, Chess960, etc.). - * All rule queries are stateless: given a GameContext, return the answer. - */ -trait RuleSet: - /** All pseudo-legal moves for the piece on `square` (ignores check). */ - def candidateMoves(context: GameContext, square: Square): List[Move] - - /** Legal moves for `square`: candidates that don't leave own king in check. */ - def legalMoves(context: GameContext, square: Square): List[Move] - - /** All legal moves for the side to move. */ - def allLegalMoves(context: GameContext): List[Move] - - /** True if the side to move's king is in check. */ - def isCheck(context: GameContext): Boolean - - /** True if the side to move is in check and has no legal moves. */ - def isCheckmate(context: GameContext): Boolean - - /** True if the side to move is not in check and has no legal moves. */ - def isStalemate(context: GameContext): Boolean - - /** True if neither side has enough material to checkmate. */ - def isInsufficientMaterial(context: GameContext): Boolean - - /** True if halfMoveClock >= 100 (50-move rule). */ - def isFiftyMoveRule(context: GameContext): Boolean -``` - -- [ ] **Step 2: Verify RuleSet compiles** - -Run: `./gradlew :modules:rule:compileScala` - -Expected: SUCCESS - -- [ ] **Step 3: Commit** - -```bash -git add modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala -git commit -m "refactor(rule): update RuleSet to use GameContext" -``` - ---- - -### Task 3: Implement StandardRules move generation engine - -**Files:** -- Modify: `modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala` - -Complete rewrite of StandardRules to implement all move generation logic using GameContext and NowChess types. - -- [ ] **Step 1: Rewrite StandardRules with full implementation** - -See plan file for complete StandardRules code. Includes: -- Direction vectors and helpers -- Public API (all RuleSet methods) -- Move generation (pawns, knights, sliding pieces, kings, castling) -- Check/checkmate/stalemate detection -- Insufficient material detection - -- [ ] **Step 2: Verify StandardRules compiles** - -Run: `./gradlew :modules:rule:compileScala` - -Expected: SUCCESS - -- [ ] **Step 3: Commit** - -```bash -git add modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala -git commit -m "refactor(rule): implement StandardRules with GameContext" -``` - ---- - -### Task 4: Configure module dependencies - -**Files:** -- Create: `modules/rule/build.gradle.kts` -- Modify: `modules/core/build.gradle.kts` - -- [ ] **Step 1: Create modules/rule/build.gradle.kts** - -See plan file for full gradle config (standard Scala module setup with api dependency). - -- [ ] **Step 2: Modify modules/core/build.gradle.kts** - -Add `implementation(project(":modules:rule"))` to dependencies. - -- [ ] **Step 3: Verify gradle build configuration** - -Run: `./gradlew :modules:rule:compileScala :modules:core:compileScala` - -Expected: SUCCESS - -- [ ] **Step 4: Commit** - -```bash -git add modules/rule/build.gradle.kts -git add modules/core/build.gradle.kts -git commit -m "build: configure rule module and add dependency" -``` - ---- - -### Task 5: Refactor GameEngine to use RuleSet directly - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` - -Major refactoring: remove GameController calls, use RuleSet for all validation, replace GameHistory with GameContext. - -- [ ] **Step 1: Update GameEngine constructor and imports** - -Inject RuleSet, replace GameHistory with GameContext, update field names. - -- [ ] **Step 2: Replace processUserInput and handleParsedMove** - -Use `ruleSet.legalMoves()` for validation, apply moves with RuleSet checks. - -- [ ] **Step 3: Update undo/redo to use GameContext** - -Use MoveCommand with previousContext instead of previousBoard/previousHistory/previousTurn. - -- [ ] **Step 4: Update reset and load methods** - -Replace GameHistory references with GameContext. - -- [ ] **Step 5: Verify GameEngine compiles** - -Run: `./gradlew :modules:core:compileScala` - -Expected: SUCCESS - -- [ ] **Step 6: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala -git commit -m "refactor(core): update GameEngine to use RuleSet, remove GameController calls" -``` - ---- - -### Task 6: Update observer events to use GameContext - -**Files:** -- Modify: All GameEvent files in `modules/core/src/main/scala/de/nowchess/chess/observer/` - -Replace (board, history, turn) parameters with GameContext in all event types. - -- [ ] **Step 1: Update all GameEvent case classes** - -For each event: -- Replace: `(board: Board, history: GameHistory, turn: Color)` -- With: `(context: GameContext)` - -Affected events: -- MoveExecutedEvent -- CheckDetectedEvent -- CheckmateEvent -- StalemateEvent -- MoveUndoneEvent -- MoveRedoneEvent -- FiftyMoveRuleAvailableEvent -- BoardResetEvent -- InvalidMoveEvent -- DrawClaimedEvent -- Others as needed - -- [ ] **Step 2: Verify compilation** - -Run: `./gradlew :modules:core:compileScala` - -Expected: SUCCESS - -- [ ] **Step 3: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/observer/ -git commit -m "refactor(observer): update GameEvent types to use GameContext" -``` - ---- - -### Task 7: Delete GameController and move logic files from core - -**Files:** -- Delete: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` -- Delete: Logic files from `modules/core/src/main/scala/de/nowchess/chess/logic/` - -- [ ] **Step 1: Delete GameController** - -```bash -rm modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala -``` - -- [ ] **Step 2: Delete logic files (moved to rule module)** - -```bash -rm modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala -rm modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala -rm modules/core/src/main/scala/de/nowchess/chess/logic/CastlingRightsCalculator.scala -rm modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala -# Delete any other logic files that are now in StandardRules -``` - -- [ ] **Step 3: Commit deletion** - -```bash -git add -u modules/core/src/main/scala/de/nowchess/chess/controller/ -git add -u modules/core/src/main/scala/de/nowchess/chess/logic/ -git commit -m "refactor(core): remove GameController and moved logic files" -``` - ---- - -### Task 8: Verify full build and green state - -**Files:** -- None (validation only) - -- [ ] **Step 1: Clean and build all modules** - -Run: `./gradlew clean build` - -Expected: SUCCESS - -- [ ] **Step 2: Run core tests** - -Run: `./gradlew :modules:core:test` - -Expected: Tests may fail (expected; tests need refactoring per spec) - -- [ ] **Step 3: Run rule tests** - -Run: `./gradlew :modules:rule:test` - -Expected: No tests yet (we'll write FEN/PGN-based tests separately) - -- [ ] **Step 4: Commit successful build state** - -```bash -git commit --allow-empty -m "build: full build succeeds post-refactoring" -``` diff --git a/docs/superpowers/plans/2026-04-05-io-interface-refactor.md b/docs/superpowers/plans/2026-04-05-io-interface-refactor.md deleted file mode 100644 index 93d2d6e..0000000 --- a/docs/superpowers/plans/2026-04-05-io-interface-refactor.md +++ /dev/null @@ -1,780 +0,0 @@ -# IO Interface Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Unify IO import/export behind uniform interfaces so GameEngine accepts any format without format-specific imports. - -**Architecture:** Change `GameContextImport` from `Option` to `Either` return; implement both FEN and PGN to this interface; refactor GameEngine to accept importer/exporter traits instead of hardcoded PgnParser. - -**Tech Stack:** Scala 3, Either, GameContext, Quarkus, ScalaTest - ---- - -## File Structure - -| File | Action | Responsibility | -|---|---|---| -| `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` | Modify | Change signature from `Option` to `Either[String, GameContext]` | -| `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` | Modify | Implement `GameContextImport`, wrap `parseFen` with error messages | -| `modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala` | Modify | Implement `GameContextImport`, call `validatePgn`, build final `GameContext` with moves | -| `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala` | Modify | Implement `GameContextExport`, build PGN from `context.moves` with default headers | -| `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` | Modify | Add `loadGame(importer, input)`, `exportGame(exporter)`; remove `loadPgn` | -| `modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala` | Modify | Update assertions from `Option` to `Either` | -| `modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala` | Modify | Add `importGameContext` test cases | -| `modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala` | Modify | Add `exportGameContext` test cases | -| `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala` | Modify | Replace `loadPgn` calls with `loadGame(PgnParser, …)`; add FEN load tests | - ---- - -## Task 1: Update GameContextImport interface - -**Files:** -- Modify: `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` - -- [ ] **Step 1: Read current GameContextImport** - -Current: -```scala -trait GameContextImport { - def importGameContext(input: String): Option[GameContext] -} -``` - -- [ ] **Step 2: Change signature to Either** - -```scala -package de.nowchess.io - -import de.nowchess.api.game.GameContext - -trait GameContextImport: - - def importGameContext(input: String): Either[String, GameContext] -``` - -- [ ] **Step 3: Verify GameContextExport unchanged** - -Confirm it still exists as: -```scala -trait GameContextExport: - def exportGameContext(context: GameContext): String -``` - ---- - -## Task 2: Update FenParser to implement Either - -**Files:** -- Modify: `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` - -- [ ] **Step 1: Update import statements** - -Add imports at top: -```scala -package de.nowchess.io.fen - -import de.nowchess.api.board.* -import de.nowchess.api.game.GameContext -import de.nowchess.io.GameContextImport -``` - -- [ ] **Step 2: Update class signature** - -Change: -```scala -object FenParser extends GameContextImport: -``` - -(It already extends GameContextImport; verify it does) - -- [ ] **Step 3: Update parseFen to return Either and call importGameContext** - -Replace the current `parseFen` return logic. Keep the body as-is but wrap returns: - -```scala -def parseFen(fen: String): Either[String, GameContext] = - val parts = fen.trim.split("\\s+") - if parts.length != 6 then - Left("Invalid FEN: expected 6 space-separated fields, got " + parts.length) - else - (for - board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position") - activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')") - castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights") - enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square") - halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)") - fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)") - if halfMoveClock >= 0 && fullMoveNumber >= 1 - yield GameContext( - board = board, - turn = activeColor, - castlingRights = castlingRights, - enPassantSquare = enPassant, - halfMoveClock = halfMoveClock, - moves = List.empty - )).left.map(err => "Invalid FEN: " + err) -``` - -- [ ] **Step 4: Implement importGameContext** - -```scala -def importGameContext(input: String): Either[String, GameContext] = parseFen(input) -``` - -- [ ] **Step 5: Verify parseBoard, parseColor, parseCastling, parseEnPassant still return Option** - -They do. They stay as-is. - ---- - -## Task 3: Update FenParserTest for Either - -**Files:** -- Modify: `modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala` - -- [ ] **Step 1: Update test "parse full FEN - initial position"** - -Change from: -```scala -context.isDefined shouldBe true -context.get.turn shouldBe Color.White -``` - -To: -```scala -context.isRight shouldBe true -context.getOrElse(??).turn shouldBe Color.White -``` - -Or use pattern match: -```scala -context match - case Right(ctx) => - ctx.turn shouldBe Color.White - ctx.castlingRights.whiteKingSide shouldBe true - case Left(err) => fail(s"Expected Right but got Left: $err") -``` - -- [ ] **Step 2: Update all "context.isDefined" to "context.isRight"** - -Search and replace: `context.isDefined` → `context.isRight`; `context.get` → `context.getOrElse(???)` or use pattern matching - -- [ ] **Step 3: Update error test cases** - -Change from: -```scala -context.isDefined shouldBe false -``` - -To: -```scala -context.isLeft shouldBe true -``` - -Example fixes: -- Line 89: `context.isDefined shouldBe false` → `context.isLeft shouldBe true` -- Line 95: `context.isDefined shouldBe false` → `context.isLeft shouldBe true` -- Line 101: `context.isDefined shouldBe false` → `context.isLeft shouldBe true` - -- [ ] **Step 4: Run FenParserTest** - -```bash -./gradlew :modules:io:test --tests "de.nowchess.io.fen.FenParserTest" -v -``` - -Expected: All tests pass. - ---- - -## Task 4: Implement PgnParser.importGameContext - -**Files:** -- Modify: `modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala` - -- [ ] **Step 1: Add GameContextImport trait to object** - -Change: -```scala -object PgnParser: -``` - -To: -```scala -object PgnParser extends GameContextImport: -``` - -- [ ] **Step 2: Implement importGameContext** - -Add this method to PgnParser: - -```scala -def importGameContext(input: String): Either[String, GameContext] = - validatePgn(input).flatMap { game => - // Replay moves to populate GameContext.moves via DefaultRules.applyMove - val (finalCtx, errors) = game.moves.foldLeft((GameContext.initial, Option.empty[String])) { - case ((ctx, Some(err)), _) => (ctx, Some(err)) // Already failed, stop - case ((ctx, None), histMove) => - val moveOpt = parseAlgebraicMove( - s"${histMove.from}${histMove.to}", - ctx, - ctx.turn - ) - moveOpt match - case None => (ctx, Some(s"Failed to parse move ${histMove.from}${histMove.to}")) - case Some(move) => - val nextCtx = DefaultRules.applyMove(ctx, move) - (nextCtx, None) - } - errors match - case Some(err) => Left(err) - case None => - if finalCtx.moves.isEmpty && game.moves.nonEmpty then - Left("No moves were parsed from the PGN") - else - Right(finalCtx) - } -``` - -- [ ] **Step 3: Ensure imports include DefaultRules** - -At top of file: -```scala -import de.nowchess.rules.sets.DefaultRules -import de.nowchess.io.GameContextImport -``` - -- [ ] **Step 4: Run PgnParserTest** - -```bash -./gradlew :modules:io:test --tests "de.nowchess.io.pgn.PgnParserTest" -v -``` - -Expected: All existing tests still pass (validatePgn is unchanged). - ---- - -## Task 5: Add importGameContext tests to PgnParserTest - -**Files:** -- Modify: `modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala` - -- [ ] **Step 1: Add test for importGameContext with valid game** - -Append to PgnParserTest: - -```scala -test("importGameContext: valid PGN returns Right with GameContext") { - val pgn = """[Event "Test"] -[White "A"] -[Black "B"] - -1. e4 e5 2. Nf3 Nc6 -""" - val result = PgnParser.importGameContext(pgn) - result.isRight shouldBe true - val ctx = result.getOrElse(???) - ctx.moves.length shouldBe 4 - ctx.turn shouldBe Color.Black -} -``` - -- [ ] **Step 2: Add test for importGameContext with invalid PGN** - -```scala -test("importGameContext: invalid PGN returns Left") { - val pgn = "[Event \"T\"]\n\n1. Qd4" - val result = PgnParser.importGameContext(pgn) - result.isLeft shouldBe true - result.left.getOrElse("").nonEmpty shouldBe true -} -``` - -- [ ] **Step 3: Add test for empty moves** - -```scala -test("importGameContext: PGN with no moves returns Right with initial position") { - val pgn = "[Event \"T\"]\n[White \"A\"]\n[Black \"B\"]\n" - val result = PgnParser.importGameContext(pgn) - result.isRight shouldBe true - val ctx = result.getOrElse(???) - ctx.moves.length shouldBe 0 - ctx.board shouldBe Board.initial -} -``` - -- [ ] **Step 4: Run tests** - -```bash -./gradlew :modules:io:test --tests "de.nowchess.io.pgn.PgnParserTest" -v -``` - -Expected: All tests pass. - ---- - -## Task 6: Update PgnExporter to implement GameContextExport - -**Files:** -- Modify: `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala` - -- [ ] **Step 1: Add trait to object signature** - -Change: -```scala -object PgnExporter: -``` - -To: -```scala -object PgnExporter extends GameContextExport: -``` - -- [ ] **Step 2: Add GameContextExport import** - -At top: -```scala -import de.nowchess.io.GameContextExport -``` - -- [ ] **Step 3: Refactor exportGame to use context.moves** - -Replace current `exportGame` implementation with one that builds PGN from `GameContext.moves`. The moves are `List[Move]` not `List[HistoryMove]`, so convert: - -```scala -def exportGame(context: GameContext): String = - // Build default headers if not present - val headers = Map( - "Event" -> "?", - "White" -> "?", - "Black" -> "?", - "Result" -> "*" - ) - - val headerLines = headers.map { case (k, v) => - s"""[$k "$v"]""" - }.mkString("\n") - - val moveText = if context.moves.isEmpty then "" - else - val grouped = context.moves.zipWithIndex.groupBy(_._2 / 2) - val lines = for (idx, movePairs) <- grouped.toList.sortBy(_._1) yield - val moveNum = idx + 1 - val whiteStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraicFromContext(p._1, context)).getOrElse("") - val blackStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraicFromContext(p._1, context)).getOrElse("") - if blackStr.isEmpty then s"$moveNum. $whiteStr" - else s"$moveNum. $whiteStr $blackStr" - lines.mkString(" ") + " *" - - if headerLines.isEmpty then moveText - else if moveText.isEmpty then headerLines - else s"$headerLines\n\n$moveText" -``` - -Wait, this is getting complex because context.moves is `List[Move]` but `moveToAlgebraicFromContext` needs the board state before the move. Let me revise: - -```scala -def exportGameContext(context: GameContext): String = - // Use the existing GameHistory-based export for now, or - // If context.moves is empty, return headers only - if context.moves.isEmpty then - val headers = Map("Event" -> "?", "White" -> "?", "Black" -> "?") - headers.map { case (k, v) => s"""[$k "$v"]""" }.mkString("\n") - else - // Replay the game to track board state and generate notation - val headerLines = "".trim // No headers from context for now (TBD: store headers in GameContext) - val moveText = replayAndExport(context.moves) - if moveText.isEmpty then "" else moveText + " *" - -private def replayAndExport(moves: List[Move]): String = - // This requires replaying moves to get board state before each move - // For now, a simplified version: - moves.zipWithIndex.map { case (move, idx) => - val moveNum = idx / 2 + 1 - val moveStr = move.moveType match - case MoveType.CastleKingside => "O-O" - case MoveType.CastleQueenside => "O-O-O" - case _ => s"${move.to}" // Simplified, loses disambiguation - val prefix = if idx % 2 == 0 then s"$moveNum. " else "" - prefix + moveStr - }.mkString(" ") -``` - -Actually, this is too complex. Let me keep the existing signature that takes headers separately for now, and just ensure `exportGameContext` delegates: - -- [ ] **Step 1 (revised): Implement exportGameContext** - -```scala -def exportGameContext(context: GameContext): String = - // Extract default headers and export from context.moves - val defaultHeaders = Map( - "Event" -> "?", - "White" -> "?", - "Black" -> "?", - "Result" -> "*" - ) - exportGameWithHeaders(defaultHeaders, context) - -private def exportGameWithHeaders(headers: Map[String, String], context: GameContext): String = - val headerLines = headers.map { case (key, value) => - s"""[$key "$value"]""" - }.mkString("\n") - - val moveText = if context.moves.isEmpty then "" - else - val groupedMoves = context.moves.zipWithIndex.groupBy(_._2 / 2) - val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield - val moveNum = moveNumber + 1 - val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraicFromMove(p._1)).getOrElse("") - val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraicFromMove(p._1)).getOrElse("") - if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" - else s"$moveNum. $whiteMoveStr $blackMoveStr" - val termination = headers.getOrElse("Result", "*") - moveLines.mkString(" ") + s" $termination" - - if headerLines.isEmpty then moveText - else if moveText.isEmpty then headerLines - else s"$headerLines\n\n$moveText" - -private def moveToAlgebraicFromMove(move: Move): String = - move.moveType match - case MoveType.CastleKingside => "O-O" - case MoveType.CastleQueenside => "O-O-O" - case _ => move.to.toString // Simplified, loses piece disambiguation -``` - -Actually this is still lossy. The real solution requires replaying to get board state. For now, keep exportGame as-is (it takes GameHistory) and make exportGameContext a thin wrapper: - -- [ ] **Step 1 (final revision): Add exportGameContext that converts context to GameHistory** - -```scala -def exportGameContext(context: GameContext): String = - val headers = Map("Event" -> "?", "White" -> "?", "Black" -> "?", "Result" -> "*") - val history = GameHistory( - moves = context.moves.map(m => - HistoryMove(m.from, m.to) // Simplified; loses castling/promotion info - ), - halfMoveClock = context.halfMoveClock - ) - exportGame(headers, history) -``` - -Wait, this loses information from context.moves (they're `Move` not `HistoryMove`). - -Let me think: the spec says "PgnExporter generates PGN from ctx.moves with default headers". But `ctx.moves` is `List[Move]` which has no castling/promotion info baked in the structure — that's in `moveType`. We'd need to replay or store richer info. - -For MVP: keep current `exportGame(headers, history)` working, and just ensure PgnExporter extends GameContextExport. We'll leave `exportGameContext` as a TODO or a simplified version that requires the caller to still use the old API for full fidelity. - -- [ ] **Step 1 (pragmatic): Add minimal exportGameContext** - -```scala -def exportGameContext(context: GameContext): String = - // Minimal implementation: returns empty PGN if moves present but no headers - if context.moves.isEmpty then "" - else - // For now, caller must use exportGame(headers, history) for full control - // This is a placeholder that returns a minimal PGN - "* (export via exportGame for full control)" -``` - -Actually, looking back at the spec: "generates PGN from `ctx.moves` with default headers". The problem is `ctx.moves` are `Move`, not `HistoryMove`. The conversion is lossy without a board replay. - -Let me re-read the design... it says `PgnExporter.exportGameContext(ctx)` generates PGN from `ctx.moves` with default headers. But to properly generate PGN (with castling notation, capture notation, piece disambiguation), we need board state before each move. - -I think the intention is: after `DefaultRules.applyMove` is called repeatedly (in `PgnParser.importGameContext`), the final `GameContext` has all moves but we lose the intermediate states. To export, we'd need to replay. - -For pragmatism and to keep the plan simple: make `exportGameContext` replay the moves from initial position to reconstruct the game, then export via the existing logic. - -- [ ] **Step 2: Implement exportGameContext with replay** - -```scala -def exportGameContext(context: GameContext): String = - val headers = Map( - "Event" -> "?", - "White" -> "?", - "Black" -> "?", - "Result" -> "*" - ) - - // Replay all moves from initial position to get HistoryMove records - val historyMoves = scala.collection.mutable.ListBuffer[HistoryMove]() - var ctx = GameContext.initial - for move <- context.moves do - val color = ctx.turn - val pieceType = ctx.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) - val isCapture = ctx.board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant - val castleSide = move.moveType match - case MoveType.CastleKingside => Some("Kingside") - case MoveType.CastleQueenside => Some("Queenside") - case _ => None - val promotionPiece = move.moveType match - case MoveType.Promotion(pp) => Some(pp) - case _ => None - historyMoves += HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture) - ctx = DefaultRules.applyMove(ctx, move) - - val history = GameHistory(historyMoves.toList, context.halfMoveClock) - exportGame(headers, history) -``` - -- [ ] **Step 3: Ensure imports** - -```scala -import de.nowchess.io.GameContextExport -import de.nowchess.api.move.MoveType -import de.nowchess.api.board.PieceType -import de.nowchess.api.game.GameContext -import de.nowchess.rules.sets.DefaultRules -``` - -- [ ] **Step 4: Run PgnExporterTest** - -```bash -./gradlew :modules:io:test --tests "de.nowchess.io.pgn.PgnExporterTest" -v -``` - -Expected: All existing tests still pass. - ---- - -## Task 7: Add exportGameContext tests to PgnExporterTest - -**Files:** -- Modify: `modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala` - -- [ ] **Step 1: Add test for round-trip (import then export)** - -```scala -test("exportGameContext: round-trip import->export preserves moves") { - val pgn = """[Event "Test"] -[White "A"] -[Black "B"] - -1. e4 e5 2. Nf3 Nc6 -""" - val importResult = PgnParser.importGameContext(pgn) - importResult.isRight shouldBe true - val ctx = importResult.getOrElse(???) - val exported = PgnExporter.exportGameContext(ctx) - - exported.contains("1. e4 e5") shouldBe true - exported.contains("2. Nf3 Nc6") shouldBe true -} -``` - -- [ ] **Step 2: Add test for empty context** - -```scala -test("exportGameContext: empty game returns headers only") { - val ctx = GameContext.initial - val exported = PgnExporter.exportGameContext(ctx) - - exported.contains("[Event") shouldBe true - exported.contains("*") shouldBe true // Result terminator -} -``` - -- [ ] **Step 3: Run tests** - -```bash -./gradlew :modules:io:test --tests "de.nowchess.io.pgn.PgnExporterTest" -v -``` - -Expected: All tests pass. - ---- - -## Task 8: Update GameEngine to add loadGame and exportGame - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` - -- [ ] **Step 1: Add loadGame method** - -Replace `loadPgn`: - -```scala -def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized { - importer.importGameContext(input) match - case Left(err) => Left(err) - case Right(ctx) => - val savedContext = currentContext - currentContext = GameContext.initial - pendingPromotion = None - invoker.clear() - - var error: Option[String] = None - - if ctx.moves.isEmpty then - // No moves: just load the position - currentContext = ctx - notifyObservers(BoardResetEvent(ctx)) - Right(()) - else - // Replay moves through the command system - ctx.moves.foreach: move => - handleParsedMove(move.from, move.to) - move.moveType match - case MoveType.Promotion(pp) => completePromotion(pp) - case _ => () - if pendingPromotion.isDefined && move.moveType != MoveType.Promotion(_) then - error = Some(s"Promotion required for move ${move.from}${move.to}") - - error match - case Some(err) => - currentContext = savedContext - Left(err) - case None => - notifyObservers(PgnLoadedEvent(currentContext)) - Right(()) -} -``` - -- [ ] **Step 2: Add exportGame method** - -```scala -def exportGame(exporter: GameContextExport): String = synchronized { - exporter.exportGameContext(currentContext) -} -``` - -- [ ] **Step 3: Remove loadPgn method** - -Delete the existing `loadPgn` entirely. - -- [ ] **Step 4: Ensure imports** - -At top of GameEngine: -```scala -import de.nowchess.io.{GameContextImport, GameContextExport} -import de.nowchess.api.move.MoveType -``` - ---- - -## Task 9: Update GameEngineLoadPgnTest - -**Files:** -- Modify: `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala` - -- [ ] **Step 1: Update test "loadPgn: valid PGN"** - -Change: -```scala -engine.loadPgn(pgn) shouldBe Right(()) -``` - -To: -```scala -engine.loadGame(PgnParser, pgn) shouldBe Right(()) -``` - -And add import: -```scala -import de.nowchess.io.pgn.PgnParser -``` - -- [ ] **Step 2: Bulk replace loadPgn calls** - -Replace all `engine.loadPgn(` with `engine.loadGame(PgnParser, ` - -Affected lines (approx): -- 23, 32, 38, 48, 58, 74, 80, 145, 146 - -- [ ] **Step 3: Add test for FEN loading** - -```scala -test("loadGame(FenParser): sets position without replaying") { - val engine = new GameEngine() - val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" - val result = engine.loadGame(FenParser, fen) - - result shouldBe Right(()) - engine.context.moves.isEmpty shouldBe true - engine.board.pieceAt(Square(File.E, Rank.R7)) shouldBe Some(Piece.WhitePawn) -} -``` - -And add import: -```scala -import de.nowchess.io.fen.FenParser -``` - -- [ ] **Step 4: Add test for exportGame** - -```scala -test("exportGame(FenExporter): exports current position as FEN") { - val engine = new GameEngine() - engine.processUserInput("e2e4") - val fen = engine.exportGame(FenExporter) - - fen.contains("e4") shouldBe false // FEN is position format, not notation - fen.contains("P") shouldBe true // Should have pawn symbol -} - -test("exportGame(PgnExporter): exports as PGN with moves") { - val engine = new GameEngine() - engine.processUserInput("e2e4") - engine.processUserInput("e7e5") - val pgn = engine.exportGame(PgnExporter) - - pgn.contains("e4") shouldBe true - pgn.contains("e5") shouldBe true -} -``` - -And add imports: -```scala -import de.nowchess.io.fen.FenExporter -import de.nowchess.io.pgn.PgnExporter -``` - -- [ ] **Step 5: Run tests** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEngineLoadPgnTest" -v -``` - -Expected: All tests pass. - ---- - -## Task 10: Full build and test - -**Files:** -- All modules - -- [ ] **Step 1: Build all** - -```bash -./gradlew build -``` - -Expected: GREEN (no errors, no test failures). - -- [ ] **Step 2: Check coverage** - -```bash -python3 jacoco-reporter/scoverage_coverage_gaps.py modules/io/build/reports/scoverageTest/scoverage.xml -``` - -Expected: No gaps in new code. - -- [ ] **Step 3: Commit all changes** - -```bash -git add -A -git commit -m "refactor(io): unify import/export interfaces with Either and GameContext" -``` - ---- - -## Summary - -After these 10 tasks: -- `GameContextImport` now returns `Either[String, GameContext]` with error messages -- `FenParser`, `PgnParser` both implement `GameContextImport` -- `PgnExporter` implements `GameContextExport` and can export from `GameContext.moves` -- `GameEngine.loadGame(importer, input)` handles any format uniformly -- `GameEngine.exportGame(exporter)` exports to any format -- All tests updated and passing -- No breaking changes to public API (only import/export interfaces changed as designed) diff --git a/docs/superpowers/specs/2026-04-03-module-refactor-interface-abstraction-design.md b/docs/superpowers/specs/2026-04-03-module-refactor-interface-abstraction-design.md deleted file mode 100644 index f59e397..0000000 --- a/docs/superpowers/specs/2026-04-03-module-refactor-interface-abstraction-design.md +++ /dev/null @@ -1,212 +0,0 @@ -# Module Refactor: Interface Abstraction Layer — NCS-22 - -**Date:** 2026-04-03 -**Epic:** NCS-20 (Reduce Token Usage) -**Task:** NCS-22 (Split module into smaller modules) -**Author:** Claude Code -**Status:** Design Approved - ---- - -## Objective - -Refactor NowChessSystems from a monolithic `modules/core` into a three-layer architecture with clean interface boundaries: -1. Reduce complexity and token usage -2. Extract rules logic into dedicated `modules/rule` -3. Establish RuleSet as the single source of truth for all chess rule decisions -4. Enable future rule variants (Chess960, etc.) via interface implementations - ---- - -## Current State - -**modules/core** conflates multiple concerns: -- `GameEngine` (state management, observer pattern) -- `GameController` (move validation orchestration) -- `GameRules`, `MoveValidator`, `CastlingRightsCalculator`, `EnPassantCalculator` (rule logic) -- Notation (PGN/FEN parsing and export) -- Command/undo system - -**Problem:** GameEngine depends directly on validation logic; no abstraction boundary; rules tightly coupled to engine implementation. - -**modules/rule** (stubbed): -- `RuleSet` trait: defines interface for rule queries -- `StandardRules` (partial): scaffolded but uses different package/type names - ---- - -## Proposed Architecture - -### Three-Layer Model - -``` -┌─ modules/api ─────────────────────────────────┐ -│ Shared types: GameContext, Board, Move, etc. │ -└───────────────────────────────────────────────┘ - ↑ -┌─ modules/rule ────────────────────────────────┐ -│ RuleSet trait (interface) │ -│ StandardRules (implementation) │ -│ All move generation & validation logic │ -└───────────────────────────────────────────────┘ - ↑ -┌─ modules/core ────────────────────────────────┐ -│ GameEngine (state + observer pattern) │ -│ Command/undo system │ -│ Notation parsers (PGN/FEN) │ -└───────────────────────────────────────────────┘ -``` - -**Dependencies:** -- `modules/rule` depends on `modules/api` -- `modules/core` depends on `modules/rule` and `modules/api` -- No circular dependencies -- `modules/api` depends only on std library - -### Core Types - -#### GameContext (new, in modules/api) - -Immutable value type bundling complete game state: - -```scala -case class GameContext( - board: Board, - turn: Color, - castlingRights: CastlingRights, - enPassantSquare: Option[Square], - halfMoveClock: Int, - moves: List[Move] // game history -): - def withBoard(newBoard: Board): GameContext = copy(board = newBoard) - def withTurn(newTurn: Color): GameContext = copy(turn = newTurn) - def withMove(move: Move): GameContext = copy(moves = moves :+ move) - // ... other immutable updates -``` - -Replaces both `Situation` (from StandardRules) and `GameHistory` (from GameEngine). - -#### RuleSet (in modules/rule) - -Single source of truth for all rule decisions: - -```scala -trait RuleSet: - def candidateMoves(context: GameContext, square: Square): List[Move] - def legalMoves(context: GameContext, square: Square): List[Move] - def allLegalMoves(context: GameContext): List[Move] - def isCheck(context: GameContext): Boolean - def isCheckmate(context: GameContext): Boolean - def isStalemate(context: GameContext): Boolean - def isInsufficientMaterial(context: GameContext): Boolean - def isFiftyMoveRule(context: GameContext): Boolean -``` - -#### StandardRules (in modules/rule) - -Concrete implementation of RuleSet for standard chess: -- Move generation (pawns, knights, bishops, rooks, queens, kings, castling, en passant) -- Check/checkmate/stalemate detection -- Insufficient material detection -- 50-move rule tracking - -Refactored from existing `StandardRules` scaffold to use NowChess types and naming conventions. No manual logic duplication from `modules/core/logic/*`. - -### GameEngine Refactoring - -**Before:** GameEngine → GameController → GameRules/MoveValidator -**After:** GameEngine → RuleSet directly - -Move from: -```scala -GameController.processMove(board, history, turn, moveInput) match - case MoveResult.Moved(...) => ... -``` - -To: -```scala -val moves = ruleSet.legalMoves(context, from) -if moves.contains(move) then - val newBoard = board.applyMove(move) - val newContext = context.withBoard(newBoard).withMove(move) - // emit event -``` - -**Removed:** -- `modules/core/controller/GameController.scala` (logic → RuleSet, orchestration → GameEngine) -- All rule logic from `modules/core/logic/*` (→ modules/rule) - -**Retained:** -- Command/undo system (depends on GameContext instead of GameHistory) -- Observer pattern (event notifications) -- PGN/FEN parsing and export - ---- - -## Design Decisions - -### Why Immutable GameContext? - -- **Enables replay:** Undo/redo regenerate state from commands -- **Thread-safe:** No synchronization needed for reads -- **Testable:** Each state change is explicit -- **Composable:** Easier to build derived contexts - -### Why Remove GameController? - -- **Not an abstraction:** It's implementation detail orchestration -- **Duplicates logic:** Validates moves, applies moves, checks outcomes — all in RuleSet now -- **Single Responsibility:** GameEngine handles I/O and state, RuleSet handles rules - -### Why RuleSet as interface? - -- **Extensibility:** Chess960, variants inherit from RuleSet -- **Testability:** Mock RuleSet for engine tests -- **Clear contract:** Engine doesn't need to know *how* moves are generated, only that RuleSet provides them - -### Test Strategy - -- **No manual board construction:** Use FEN for position setup -- **Use PGN for move validation:** Assert sequences of moves are legal -- **RuleSet tests:** Direct unit tests of move generation, check detection, etc. (all via FEN/PGN) -- **GameEngine tests:** Verify event emission and state transitions with RuleSet mocks or real RuleSet - ---- - -## Files to Create/Modify - -| Action | File | Purpose | -|--------|------|---------| -| **Create** | `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` | Immutable game state | -| **Refactor** | `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` | Interface definition | -| **Rewrite** | `modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala` | Implementation (adapted from scaffold) | -| **Create** | `modules/rule/build.gradle.kts` | Gradle config with api dependency | -| **Refactor** | `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` | Call RuleSet directly | -| **Delete** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | No longer needed | -| **Delete** | `modules/core/src/main/scala/de/nowchess/chess/logic/*.scala` | Move to modules/rule | -| **Update** | `modules/core/build.gradle.kts` | Add rule dependency | -| **Update** | `settings.gradle.kts` | Already includes rule; no changes needed | - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| GameEngine refactor breaks observer/undo | Keep observer and command patterns intact; only change what RuleSet returns | -| GameContext replaces two types (Situation/GameHistory) | Design GameContext upfront; validate it works with undo/redo before full migration | -| Move logic extraction from core is fragile | Extract incrementally: extract one type at a time, validate with existing tests first | -| PGN/FEN still depend on core classes | Create wrapper types in api if needed; avoid circular deps | - ---- - -## Done Criteria - -- [ ] GameContext type created and used in RuleSet -- [ ] RuleSet interface and StandardRules implementation complete -- [ ] GameEngine refactored to call RuleSet (no GameController) -- [ ] All rule logic extracted from modules/core to modules/rule -- [ ] No circular dependencies -- [ ] Build succeeds -- [ ] Regression tests written using FEN/PGN (not manual boards) -- [ ] Code freeze can be lifted \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-05-io-interface-refactor-design.md b/docs/superpowers/specs/2026-04-05-io-interface-refactor-design.md deleted file mode 100644 index fa9ee7b..0000000 --- a/docs/superpowers/specs/2026-04-05-io-interface-refactor-design.md +++ /dev/null @@ -1,91 +0,0 @@ -# IO Interface Refactor Design -Date: 2026-04-05 - -## Goal - -Make `GameEngine` accept any IO format (FEN, PGN, future formats) through a uniform interface, so callers never depend on format-specific classes directly. - -## Problem - -`GameContextImport` returns `Option[GameContext]`, losing error messages. `PgnParser` and `PgnExporter` do not implement either interface — the engine imports `PgnParser` directly in `loadPgn`. This breaks the abstraction the interfaces are meant to provide. - -## Interface Changes - -### `GameContextImport` (modules/io) - -Change return type from `Option` to `Either`: - -```scala -trait GameContextImport: - def importGameContext(input: String): Either[String, GameContext] -``` - -### `GameContextExport` (modules/io) - -Unchanged: - -```scala -trait GameContextExport: - def exportGameContext(context: GameContext): String -``` - -## Implementations - -| Class | Trait | Behaviour | -|---|---|---| -| `FenParser` | `GameContextImport` | `parseFen` → `Right(ctx)` or `Left("Invalid FEN: …")` | -| `FenExporter` | `GameContextExport` | unchanged — delegates to `gameContextToFen` | -| `PgnParser` | `GameContextImport` | calls `validatePgn`; maps `Right(game)` to final `GameContext` with `moves` populated via `DefaultRules.applyMove`; passes through `Left(err)` | -| `PgnExporter` | `GameContextExport` | generates PGN from `ctx.moves` with default headers | - -`PgnParser` retains `parsePgn`, `validatePgn`, and `parseAlgebraicMove` as its own public API. `importGameContext` is the additional uniform entry point. - -## GameEngine Changes (modules/core) - -Remove `loadPgn(pgn: String)`. Add: - -```scala -def loadGame(importer: GameContextImport, input: String): Either[String, Unit] -``` - -Logic inside `loadGame`: -1. Call `importer.importGameContext(input)` -2. On `Left(err)` → return `Left(err)` -3. On `Right(ctx)`: - - `ctx.moves.nonEmpty` → replay each move through `handleParsedMove` + `completePromotion`, then notify `PgnLoadedEvent` - - `ctx.moves.isEmpty` → call `loadPosition(ctx)` - -Add symmetric export: - -```scala -def exportGame(exporter: GameContextExport): String = - exporter.exportGameContext(context) -``` - -`loadPosition` is kept unchanged for direct `GameContext` injection (tests, GUI, reset). - -Callers: -```scala -engine.loadGame(PgnParser, pgn) // game with history → replay -engine.loadGame(FenParser, fen) // position snapshot → set position -engine.exportGame(FenExporter) -engine.exportGame(PgnExporter) -``` - -## Testing - -### Updates to existing tests -- `FenParserTest` — update assertions from `Option` to `Either` -- `FenExporterTest` — no changes expected -- `GameEngineLoadPgnTest` — replace `engine.loadPgn(pgn)` with `engine.loadGame(PgnParser, pgn)` - -### New test cases (in existing test files) -- `PgnParserTest` — `importGameContext` returns `Right(ctx)` with correct final position and `ctx.moves` populated; returns `Left(err)` on invalid PGN -- `PgnExporterTest` — `exportGameContext(ctx)` generates valid PGN from a context with moves -- `GameEngineLoadPgnTest` / `GameEngineTest` — `loadGame(FenParser, fen)` sets position without replay; `loadGame(PgnParser, pgn)` replays moves and enables undo/redo; `exportGame` delegates correctly to both exporters - -## Out of Scope - -- Adding new formats (no new parsers/exporters in this change) -- PGN header customisation on export (default headers only for now) -- Changes to `GameHistory` (already deprecated, not touched)