diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 7c8b6fd..113b2d1 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -6,9 +6,9 @@ import de.nowchess.api.game.GameContext import de.nowchess.chess.controller.Parser import de.nowchess.chess.observer.* import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} -import de.nowchess.chess.notation.{PgnExporter, PgnParser} +import de.nowchess.io.pgn.PgnParser import de.nowchess.rules.RuleSet -import de.nowchess.rules.sets.StandardRules +import de.nowchess.rules.sets.DefaultRules /** Pure game engine that manages game state and notifies observers of state changes. * All rule queries delegate to the injected RuleSet. @@ -16,7 +16,7 @@ import de.nowchess.rules.sets.StandardRules */ class GameEngine( initialContext: GameContext = GameContext.initial, - ruleSet: RuleSet = StandardRules + ruleSet: RuleSet = DefaultRules ) extends Observable: private var currentContext: GameContext = initialContext private val invoker = new CommandInvoker() @@ -146,25 +146,16 @@ class GameEngine( invoker.clear() var error: Option[String] = None - import scala.util.control.Breaks._ - breakable { - game.moves.foreach { histMove => - handleParsedMove(histMove.from, histMove.to) - histMove.promotionPiece.foreach(completePromotion) - // $COVERAGE-OFF$ unreachable: PgnParser.validatePgn ensures promotion annotation is present + game.moves.foreach: histMove => + handleParsedMove(histMove.from, histMove.to) + histMove.promotionPiece.foreach(completePromotion) if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then - error = Some(s"Promotion required for move ${histMove.from}${histMove.to}") - break() - // $COVERAGE-ON$ - } - } + error = Some(s"Promotion required for move ${histMove.from}${histMove.to}") error match - // $COVERAGE-OFF$ unreachable: error is only set in the unreachable block above case Some(err) => currentContext = savedContext Left(err) - // $COVERAGE-ON$ case None => notifyObservers(PgnLoadedEvent(currentContext)) Right(()) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala index 136d1c2..09623ad 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala @@ -151,7 +151,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: test("loadPosition: fires BoardResetEvent and updates context"): import de.nowchess.api.board.{Board, Color} import de.nowchess.api.game.GameContext - import de.nowchess.chess.notation.FenParser + import de.nowchess.io.fen.FenParser val engine = new GameEngine() val cap = new EventCapture() diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala index d4a3f83..0ee7678 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala @@ -3,7 +3,7 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Rank, Square} import de.nowchess.api.game.GameContext import de.nowchess.api.move.PromotionPiece -import de.nowchess.chess.notation.FenParser +import de.nowchess.io.fen.FenParser import de.nowchess.chess.observer.* import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 62e274d..14022a2 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -3,10 +3,10 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} -import de.nowchess.chess.notation.FenParser +import de.nowchess.io.fen.FenParser import de.nowchess.chess.observer.* import de.nowchess.rules.RuleSet -import de.nowchess.rules.sets.StandardRules +import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -154,27 +154,27 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: // triggering the "Error completing promotion." branch. val delegatingRuleSet: RuleSet = new RuleSet: def candidateMoves(context: GameContext, square: Square): List[Move] = - StandardRules.candidateMoves(context, square) + DefaultRules.candidateMoves(context, square) def legalMoves(context: GameContext, square: Square): List[Move] = - StandardRules.legalMoves(context, square).map { m => + DefaultRules.legalMoves(context, square).map { m => m.moveType match case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal) case _ => m } def allLegalMoves(context: GameContext): List[Move] = - StandardRules.allLegalMoves(context) + DefaultRules.allLegalMoves(context) def isCheck(context: GameContext): Boolean = - StandardRules.isCheck(context) + DefaultRules.isCheck(context) def isCheckmate(context: GameContext): Boolean = - StandardRules.isCheckmate(context) + DefaultRules.isCheckmate(context) def isStalemate(context: GameContext): Boolean = - StandardRules.isStalemate(context) + DefaultRules.isStalemate(context) def isInsufficientMaterial(context: GameContext): Boolean = - StandardRules.isInsufficientMaterial(context) + DefaultRules.isInsufficientMaterial(context) def isFiftyMoveRule(context: GameContext): Boolean = - StandardRules.isFiftyMoveRule(context) + DefaultRules.isFiftyMoveRule(context) def applyMove(context: GameContext, move: Move): GameContext = - StandardRules.applyMove(context, move) + DefaultRules.applyMove(context, move) val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White) diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts index c006936..8c8fffb 100644 --- a/modules/io/build.gradle.kts +++ b/modules/io/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { } implementation(project(":modules:api")) + implementation(project(":modules:rule")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala b/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala index fa1af99..6690f81 100644 --- a/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala +++ b/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala @@ -3,7 +3,7 @@ package de.nowchess.io import de.nowchess.api.game.GameContext trait GameContextImport { - - def importGameContext(input: String): GameContext + + def importGameContext(input: String): Option[GameContext] } diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala similarity index 88% rename from modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala rename to modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala index 50c8108..1af88a6 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala @@ -1,9 +1,10 @@ -package de.nowchess.chess.notation +package de.nowchess.io.fen import de.nowchess.api.board.* import de.nowchess.api.game.GameContext +import de.nowchess.io.GameContextExport -object FenExporter: +object FenExporter extends GameContextExport: /** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */ def boardToFen(board: Board): String = @@ -23,7 +24,7 @@ object FenExporter: if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0) emptyCount = 0 - rankChars += pieceToPgnChar(piece) + rankChars += pieceToFenChar(piece) case None => emptyCount += 1 @@ -39,6 +40,8 @@ object FenExporter: val fullMoveNumber = 1 + (context.moves.length / 2) s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber" + def exportGameContext(context: GameContext): String = gameContextToFen(context) + /** Convert castling rights to FEN notation. */ private def castlingString(rights: CastlingRights): String = val wk = if rights.whiteKingSide then "K" else "" @@ -49,7 +52,7 @@ object FenExporter: if result.isEmpty then "-" else result /** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */ - private def pieceToPgnChar(piece: Piece): Char = + private def pieceToFenChar(piece: Piece): Char = val base = piece.pieceType match case PieceType.Pawn => 'p' case PieceType.Knight => 'n' @@ -58,3 +61,4 @@ object FenExporter: case PieceType.Queen => 'q' case PieceType.King => 'k' if piece.color == Color.White then base.toUpper else base + diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala similarity index 95% rename from modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala rename to modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala index 5b20243..1411f68 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala @@ -1,9 +1,10 @@ -package de.nowchess.chess.notation +package de.nowchess.io.fen import de.nowchess.api.board.* import de.nowchess.api.game.GameContext +import de.nowchess.io.GameContextImport -object FenParser: +object FenParser extends GameContextImport: /** Parse a complete FEN string into a GameContext. * Returns None if the format is invalid. */ @@ -27,6 +28,8 @@ object FenParser: moves = List.empty ) + def importGameContext(input: String): Option[GameContext] = parseFen(input) + /** Parse active color ("w" or "b"). */ private def parseColor(s: String): Option[Color] = if s == "w" then Some(Color.White) @@ -102,3 +105,4 @@ object FenParser: case 'k' => Some(PieceType.King) case _ => None pieceTypeOpt.map(pt => Piece(color, pt)) + diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala similarity index 96% rename from modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala rename to modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala index a851ce1..1a177de 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala @@ -1,6 +1,6 @@ -package de.nowchess.chess.notation +package de.nowchess.io.pgn -import de.nowchess.api.board.{PieceType, *} +import de.nowchess.api.board.* import de.nowchess.api.move.PromotionPiece import de.nowchess.api.game.{GameHistory, HistoryMove} @@ -52,3 +52,5 @@ object PgnExporter: case PieceType.Rook => s"R$capStr$dest$promSuffix" case PieceType.Queen => s"Q$capStr$dest$promSuffix" case PieceType.King => s"K$capStr$dest$promSuffix" + + diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala similarity index 93% rename from modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala rename to modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala index 5152a98..20ffe5d 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala @@ -1,9 +1,9 @@ -package de.nowchess.chess.notation +package de.nowchess.io.pgn import de.nowchess.api.board.* import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.game.{GameContext, HistoryMove} -import de.nowchess.rules.sets.StandardRules +import de.nowchess.rules.sets.DefaultRules /** A parsed PGN game containing headers and the resolved move list. */ case class PgnGame( @@ -35,7 +35,7 @@ object PgnParser: /** Parse PGN header lines of the form [Key "Value"]. */ private def parseHeaders(lines: Array[String]): Map[String, String] = - val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r + val pattern = """^\[(\w+)\s+"([^"]*)"\s*]$""".r lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap /** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */ @@ -51,7 +51,7 @@ object PgnParser: case None => state case Some(move) => val histMove = toHistoryMove(move, ctx, color) - val nextCtx = StandardRules.applyMove(ctx, move) + val nextCtx = DefaultRules.applyMove(ctx, move) (nextCtx, color.opposite, acc :+ histMove) moves @@ -82,12 +82,12 @@ object PgnParser: case "O-O" | "O-O+" | "O-O#" => val rank = if color == Color.White then Rank.R1 else Rank.R8 val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside) - Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) + Option.when(DefaultRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) case "O-O-O" | "O-O-O+" | "O-O-O#" => val rank = if color == Color.White then Rank.R1 else Rank.R8 val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside) - Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) + Option.when(DefaultRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) case _ => parseRegularMove(notation, ctx, color) @@ -118,7 +118,7 @@ object PgnParser: val promotion = extractPromotion(notation) // Get all legal moves for this color that reach toSquare - val allLegal = StandardRules.allLegalMoves(ctx) + val allLegal = DefaultRules.allLegalMoves(ctx) val candidates = allLegal.filter { move => move.to == toSquare && ctx.board.pieceAt(move.from).exists(p => @@ -146,7 +146,7 @@ object PgnParser: case Some(pp) => move.moveType == MoveType.Promotion(pp) /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ - private[notation] def extractPromotion(notation: String): Option[PromotionPiece] = + private[pgn] def extractPromotion(notation: String): Option[PromotionPiece] = val promotionPattern = """=([A-Z])""".r promotionPattern.findFirstMatchIn(notation).flatMap { m => m.group(1) match @@ -181,7 +181,9 @@ object PgnParser: case None => Left(s"Illegal or impossible move: '$token'") case Some(move) => val histMove = toHistoryMove(move, ctx, color) - val nextCtx = StandardRules.applyMove(ctx, move) + val nextCtx = DefaultRules.applyMove(ctx, move) Right((nextCtx, color.opposite, moves :+ histMove)) } }.map(_._3) + + diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala similarity index 98% rename from modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala rename to modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala index ea572fa..4ce01eb 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala @@ -1,4 +1,4 @@ -package de.nowchess.chess.notation +package de.nowchess.io.fen import de.nowchess.api.board.* import de.nowchess.api.game.GameContext @@ -99,3 +99,4 @@ class FenExporterTest extends AnyFunSuite with Matchers: FenParser.parseFen(fen) match case Some(ctx) => ctx.halfMoveClock shouldBe 42 case None => fail("FEN parsing failed") + diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala similarity index 99% rename from modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala rename to modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala index b4752b6..420695f 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala @@ -1,4 +1,4 @@ -package de.nowchess.chess.notation +package de.nowchess.io.fen import de.nowchess.api.board.* import org.scalatest.funsuite.AnyFunSuite @@ -130,3 +130,4 @@ class FenParserTest extends AnyFunSuite with Matchers: val board = FenParser.parseBoard(fen) board shouldBe empty + diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala similarity index 99% rename from modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala rename to modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala index 6a4f15a..af7de95 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala @@ -1,4 +1,4 @@ -package de.nowchess.chess.notation +package de.nowchess.io.pgn import de.nowchess.api.board.{PieceType, *} import de.nowchess.api.move.PromotionPiece @@ -112,3 +112,4 @@ class PgnExporterTest extends AnyFunSuite with Matchers: .addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) val pgn = PgnExporter.exportGame(Map.empty, history) pgn shouldBe "1. e4 *" + diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala similarity index 99% rename from modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala rename to modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala index 7694a9d..d7e8db4 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala @@ -1,9 +1,9 @@ -package de.nowchess.chess.notation +package de.nowchess.io.pgn import de.nowchess.api.board.* import de.nowchess.api.move.{MoveType, PromotionPiece} import de.nowchess.api.game.GameContext -import de.nowchess.chess.notation.FenParser +import de.nowchess.io.fen.FenParser import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -437,3 +437,4 @@ class PgnParserTest extends AnyFunSuite with Matchers: val result = PgnParser.extractPromotion("e7e8") result shouldBe None } + diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala similarity index 99% rename from modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala rename to modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala index ff80cb6..c105599 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala @@ -1,4 +1,4 @@ -package de.nowchess.chess.notation +package de.nowchess.io.pgn import de.nowchess.api.board.* import de.nowchess.api.move.PromotionPiece @@ -117,3 +117,4 @@ class PgnValidatorTest extends AnyFunSuite with Matchers: // This is hard to construct via PGN alone; verify via a known impossible disambiguation val pgn = "[Event \"T\"]\n\n1. e4" PgnParser.validatePgn(pgn).isRight shouldBe true + diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/StandardRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala similarity index 99% rename from modules/rule/src/main/scala/de/nowchess/rules/sets/StandardRules.scala rename to modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index cb51c1c..3d95679 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/StandardRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -10,7 +10,7 @@ import scala.annotation.tailrec /** Standard chess rules implementation. * Handles move generation, validation, check/checkmate/stalemate detection. */ -object StandardRules extends RuleSet: +object DefaultRules extends RuleSet: // ── Direction vectors ────────────────────────────────────────────── private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))