This commit is contained in:
@@ -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"
|
|
||||||
```
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user