diff --git a/modules/api/src/main/scala/de/nowchess/api/game/HistoryMove.scala b/modules/api/src/main/scala/de/nowchess/api/game/HistoryMove.scala deleted file mode 100644 index bbb8616..0000000 --- a/modules/api/src/main/scala/de/nowchess/api/game/HistoryMove.scala +++ /dev/null @@ -1,15 +0,0 @@ -package de.nowchess.api.game - -import de.nowchess.api.board.{PieceType, Square} -import de.nowchess.api.move.PromotionPiece - -/** A single move recorded in game history and PGN workflows. */ -case class HistoryMove( - from: Square, - to: Square, - castleSide: Option[String] = None, - promotionPiece: Option[PromotionPiece] = None, - pieceType: PieceType = PieceType.Pawn, - isCapture: Boolean = false -) - diff --git a/modules/api/src/main/scala/de/nowchess/api/move/Move.scala b/modules/api/src/main/scala/de/nowchess/api/move/Move.scala index fb3ff79..1485c93 100644 --- a/modules/api/src/main/scala/de/nowchess/api/move/Move.scala +++ b/modules/api/src/main/scala/de/nowchess/api/move/Move.scala @@ -9,7 +9,7 @@ enum PromotionPiece: /** Classifies special move semantics beyond a plain quiet move or capture. */ enum MoveType: /** A normal move or capture with no special rule. */ - case Normal + case Normal(isCapture: Boolean = false) /** Kingside castling (O-O). */ case CastleKingside /** Queenside castling (O-O-O). */ @@ -29,5 +29,5 @@ enum MoveType: final case class Move( from: Square, to: Square, - moveType: MoveType = MoveType.Normal + moveType: MoveType = MoveType.Normal() ) diff --git a/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala index d788f83..d0d64a1 100644 --- a/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala @@ -11,7 +11,12 @@ class MoveTest extends AnyFunSuite with Matchers: test("Move defaults moveType to Normal") { val m = Move(e2, e4) - m.moveType shouldBe MoveType.Normal + m.moveType shouldBe MoveType.Normal() + } + + test("MoveType.Normal supports capture flag") { + val m = Move(e2, e4, MoveType.Normal(isCapture = true)) + m.moveType shouldBe MoveType.Normal(true) } test("Move stores from and to squares") { 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 3db2286..7d2a693 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 @@ -242,8 +242,7 @@ class GameEngine( case PromotionPiece.Bishop => "B" case PromotionPiece.Knight => "N" s"${move.to}=$ppChar" - case MoveType.Normal => - val isCapture = boardBefore.pieceAt(move.to).isDefined + case MoveType.Normal(isCapture) => boardBefore.pieceAt(move.from).map(_.pieceType) match case Some(PieceType.Pawn) => if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}" 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 14022a2..078d4f4 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 @@ -158,7 +158,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: def legalMoves(context: GameContext, square: Square): List[Move] = DefaultRules.legalMoves(context, square).map { m => m.moveType match - case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal) + case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal()) case _ => m } def allLegalMoves(context: GameContext): List[Move] = diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/HistoryMoveTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/HistoryMoveTest.scala deleted file mode 100644 index 0c375c3..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/HistoryMoveTest.scala +++ /dev/null @@ -1,48 +0,0 @@ -package de.nowchess.chess.logic - -import de.nowchess.api.board.* -import de.nowchess.api.move.PromotionPiece -import de.nowchess.api.game.HistoryMove -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class HistoryMoveTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - - test("Move with promotion records the promotion piece"): - val move = HistoryMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Queen)) - move.promotionPiece should be (Some(PromotionPiece.Queen)) - - test("Normal move has no promotion piece"): - val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), None, None) - move.promotionPiece should be (None) - - test("HistoryMove default values are compatible with normal pawn move"): - val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - move.castleSide shouldBe None - move.promotionPiece shouldBe None - move.pieceType shouldBe PieceType.Pawn - move.isCapture shouldBe false - - test("HistoryMove stores castle side and capture marker"): - val move = HistoryMove( - from = sq(File.E, Rank.R1), - to = sq(File.G, Rank.R1), - castleSide = Some("Kingside"), - pieceType = PieceType.King - ) - move.castleSide shouldBe Some("Kingside") - move.pieceType shouldBe PieceType.King - - test("HistoryMove stores explicit piece and capture flags"): - val move = HistoryMove( - from = sq(File.G, Rank.R1), - to = sq(File.F, Rank.R3), - pieceType = PieceType.Knight, - isCapture = true - ) - move.pieceType shouldBe PieceType.Knight - move.isCapture shouldBe true - - diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala index 15bf187..5592caa 100644 --- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala @@ -1,14 +1,14 @@ package de.nowchess.io.pgn import de.nowchess.api.board.* -import de.nowchess.api.move.{PromotionPiece, MoveType} -import de.nowchess.api.game.{GameContext, HistoryMove} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.api.game.GameContext import de.nowchess.io.GameContextExport import de.nowchess.rules.sets.DefaultRules object PgnExporter extends GameContextExport: - /** Export a GameContext to PGN format by replaying all moves and reconstructing HistoryMove records. */ + /** Export a GameContext to PGN format. */ def exportGameContext(context: GameContext): String = val headers = Map( "Event" -> "?", @@ -17,46 +17,28 @@ object PgnExporter extends GameContextExport: "Result" -> "*" ) - // Replay all moves to reconstruct HistoryMove records with full info - 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 { - val fromStr = move.from.toString - val boardStr = ctx.board.pieces.keys.map(_.toString).mkString(",") - throw new IllegalStateException( - s"Invariant violation: no piece at $fromStr during PGN export replay. Board squares: $boardStr" - ) - } - 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) + exportGame(headers, context.moves) - exportGame(headers, historyMoves.toList) - - /** Export a game with headers and history moves to PGN format. */ - def exportGame(headers: Map[String, String], moves: List[HistoryMove]): String = + /** Export a game with headers and moves to PGN format. */ + def exportGame(headers: Map[String, String], moves: List[Move]): String = val headerLines = headers.map { case (key, value) => s"""[$key "$value"]""" }.mkString("\n") val moveText = if moves.isEmpty then "" else - val groupedMoves = moves.zipWithIndex.groupBy(_._2 / 2) + var ctx = GameContext.initial + val sanMoves = moves.map { move => + val algebraic = moveToAlgebraic(move, ctx.board) + ctx = DefaultRules.applyMove(ctx, move) + algebraic + } + + val groupedMoves = sanMoves.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 => moveToAlgebraic(p._1)).getOrElse("") - val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("") + val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") + val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" else s"$moveNum. $whiteMoveStr $blackMoveStr" @@ -67,28 +49,32 @@ object PgnExporter extends GameContextExport: else if moveText.isEmpty then headerLines else s"$headerLines\n\n$moveText" - /** Convert a HistoryMove to Standard Algebraic Notation. */ - def moveToAlgebraic(move: HistoryMove): String = - move.castleSide match - case Some("Kingside") => "O-O" - case Some("Queenside") => "O-O-O" - case Some(_) | None => - val dest = move.to.toString - val capStr = if move.isCapture then "x" else "" - val promSuffix = move.promotionPiece match - case Some(PromotionPiece.Queen) => "=Q" - case Some(PromotionPiece.Rook) => "=R" - case Some(PromotionPiece.Bishop) => "=B" - case Some(PromotionPiece.Knight) => "=N" - case None => "" - move.pieceType match + /** Convert a Move to Standard Algebraic Notation using the board state before the move. */ + private def moveToAlgebraic(move: Move, boardBefore: Board): String = + move.moveType match + case MoveType.CastleKingside => "O-O" + case MoveType.CastleQueenside => "O-O-O" + case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}" + case MoveType.Promotion(pp) => + val promSuffix = pp match + case PromotionPiece.Queen => "=Q" + case PromotionPiece.Rook => "=R" + case PromotionPiece.Bishop => "=B" + case PromotionPiece.Knight => "=N" + val isCapture = boardBefore.pieceAt(move.to).isDefined + if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}$promSuffix" + else s"${move.to}$promSuffix" + case MoveType.Normal(isCapture) => + val dest = move.to.toString + val capStr = if isCapture then "x" else "" + boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match case PieceType.Pawn => - if move.isCapture then s"${move.from.file.toString.toLowerCase}x$dest$promSuffix" - else s"$dest$promSuffix" - case PieceType.Knight => s"N$capStr$dest$promSuffix" - case PieceType.Bishop => s"B$capStr$dest$promSuffix" - case PieceType.Rook => s"R$capStr$dest$promSuffix" - case PieceType.Queen => s"Q$capStr$dest$promSuffix" - case PieceType.King => s"K$capStr$dest$promSuffix" + if isCapture then s"${move.from.file.toString.toLowerCase}x$dest" + else dest + case PieceType.Knight => s"N$capStr$dest" + case PieceType.Bishop => s"B$capStr$dest" + case PieceType.Rook => s"R$capStr$dest" + case PieceType.Queen => s"Q$capStr$dest" + case PieceType.King => s"K$capStr$dest" diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala index 1c36afa..1665ca6 100644 --- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala @@ -2,14 +2,14 @@ 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.api.game.GameContext import de.nowchess.io.GameContextImport import de.nowchess.rules.sets.DefaultRules /** A parsed PGN game containing headers and the resolved move list. */ case class PgnGame( headers: Map[String, String], - moves: List[HistoryMove] + moves: List[Move] ) object PgnParser extends GameContextImport: @@ -29,24 +29,7 @@ object PgnParser extends GameContextImport: * Returns Left(error message) if validation fails or move replay encounters an issue. */ 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 => Right(finalCtx) + Right(game.moves.foldLeft(GameContext.initial)(DefaultRules.applyMove)) } /** Parse a complete PGN text into a PgnGame with headers and moves. @@ -64,11 +47,11 @@ object PgnParser extends GameContextImport: 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. */ - private def parseMovesText(moveText: String): List[HistoryMove] = + /** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved Moves. */ + private def parseMovesText(moveText: String): List[Move] = val tokens = moveText.split("\\s+").filter(_.nonEmpty) val (_, _, moves) = tokens.foldLeft( - (GameContext.initial, Color.White, List.empty[HistoryMove]) + (GameContext.initial, Color.White, List.empty[Move]) ): case (state @ (ctx, color, acc), token) => if isMoveNumberOrResult(token) then state @@ -76,24 +59,10 @@ object PgnParser extends GameContextImport: parseAlgebraicMove(token, ctx, color) match case None => state case Some(move) => - val histMove = toHistoryMove(move, ctx, color) val nextCtx = DefaultRules.applyMove(ctx, move) - (nextCtx, color.opposite, acc :+ histMove) + (nextCtx, color.opposite, acc :+ move) moves - /** Convert an api.move.Move to a HistoryMove given the board context before the move. */ - private def toHistoryMove(move: Move, ctx: GameContext, color: Color): HistoryMove = - 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 - val pieceType = ctx.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) - val isCapture = ctx.board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant - HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture) - /** True for move-number tokens ("1.", "12.") and PGN result tokens. */ private def isMoveNumberOrResult(token: String): Boolean = token.matches("""\d+\.""") || @@ -167,8 +136,9 @@ object PgnParser extends GameContextImport: private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean = promotion match - case None => move.moveType == MoveType.Normal || move.moveType == MoveType.EnPassant || - move.moveType == MoveType.CastleKingside || move.moveType == MoveType.CastleQueenside + case None => move.moveType match + case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true + case _ => false case Some(pp) => move.moveType == MoveType.Promotion(pp) /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ @@ -196,9 +166,9 @@ object PgnParser extends GameContextImport: // ── Strict validation helpers ───────────────────────────────────────────── /** Walk all move tokens, failing immediately on any unresolvable or illegal move. */ - private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] = + private def validateMovesText(moveText: String): Either[String, List[Move]] = val tokens = moveText.split("\\s+").filter(_.nonEmpty) - tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[HistoryMove])): Either[String, (GameContext, Color, List[HistoryMove])]) { + tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) { case (acc, token) => acc.flatMap { case (ctx, color, moves) => if isMoveNumberOrResult(token) then Right((ctx, color, moves)) @@ -206,9 +176,8 @@ object PgnParser extends GameContextImport: parseAlgebraicMove(token, ctx, color) match case None => Left(s"Illegal or impossible move: '$token'") case Some(move) => - val histMove = toHistoryMove(move, ctx, color) val nextCtx = DefaultRules.applyMove(ctx, move) - Right((nextCtx, color.opposite, moves :+ histMove)) + Right((nextCtx, color.opposite, moves :+ move)) } }.map(_._3) diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala index b96dc86..ff01a4d 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala @@ -2,7 +2,7 @@ package de.nowchess.io.pgn import de.nowchess.api.board.{PieceType, *} import de.nowchess.api.move.{PromotionPiece, Move, MoveType} -import de.nowchess.api.game.{GameContext, HistoryMove} +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -19,7 +19,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("export single move") { val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") - val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) + val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) val pgn = PgnExporter.exportGame(headers, moves) pgn.contains("1. e4") shouldBe true @@ -27,7 +27,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("export castling") { val headers = Map("Event" -> "Test") - val moves = List(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some("Kingside"))) + val moves = List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)) val pgn = PgnExporter.exportGame(headers, moves) pgn.contains("O-O") shouldBe true @@ -36,9 +36,9 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("export game sequence") { val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0") val moves = List( - HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None), - HistoryMove(Square(File.C, Rank.R7), Square(File.C, Rank.R5), None), - HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None, pieceType = PieceType.Knight) + Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)), + Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal(false)), + Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal(false)) ) val pgn = PgnExporter.exportGame(headers, moves) @@ -47,7 +47,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers: } test("export game with no headers returns only move text") { - val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) + val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn shouldBe "1. e4 *" @@ -55,57 +55,57 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("export queenside castling") { val headers = Map("Event" -> "Test") - val moves = List(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some("Queenside"))) + val moves = List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)) val pgn = PgnExporter.exportGame(headers, moves) pgn.contains("O-O-O") shouldBe true } test("exportGame encodes promotion to Queen as =Q suffix") { - val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen))) + val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn should include ("e8=Q") } test("exportGame encodes promotion to Rook as =R suffix") { - val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook))) + val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn should include ("e8=R") } test("exportGame encodes promotion to Bishop as =B suffix") { - val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop))) + val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn should include ("e8=B") } test("exportGame encodes promotion to Knight as =N suffix") { - val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight))) + val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn should include ("e8=N") } test("exportGame does not add suffix for normal moves") { - val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None)) + val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn should include ("e4") pgn should not include "=" } test("exportGame uses Result header as termination marker"): - val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) + val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), moves) pgn should endWith("1/2-1/2") test("exportGame with no Result header still uses * as default"): - val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) + val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) val pgn = PgnExporter.exportGame(Map.empty, moves) pgn shouldBe "1. e4 *" test("exportGameContext: moves are preserved in output") { val moves = List( - Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal), - Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal) + Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)), + Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal(false)) ) val ctx = GameContext.initial.copy(moves = moves) val exported = PgnExporter.exportGameContext(ctx) diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala index 1a4c2f1..a917817 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala @@ -73,7 +73,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: val lastMove = game.get.moves.last lastMove.from shouldBe Square(File.E, Rank.R1) lastMove.to shouldBe Square(File.G, Rank.R1) - lastMove.castleSide.isDefined shouldBe true + lastMove.moveType shouldBe MoveType.CastleKingside } test("parse PGN empty moves") { @@ -98,7 +98,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: game.isDefined shouldBe true val blackCastle = game.get.moves.last - blackCastle.castleSide shouldBe Some("Kingside") + blackCastle.moveType shouldBe MoveType.CastleKingside blackCastle.from shouldBe Square(File.E, Rank.R8) blackCastle.to shouldBe Square(File.G, Rank.R8) } @@ -225,7 +225,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.castleSide shouldBe Some("Queenside") + lastMove.moveType shouldBe MoveType.CastleQueenside lastMove.from shouldBe Square(File.E, Rank.R1) lastMove.to shouldBe Square(File.C, Rank.R1) } @@ -240,7 +240,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.castleSide shouldBe Some("Queenside") + lastMove.moveType shouldBe MoveType.CastleQueenside lastMove.from shouldBe Square(File.E, Rank.R8) lastMove.to shouldBe Square(File.C, Rank.R8) } @@ -326,7 +326,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: result.get.to shouldBe Square(File.D, Rank.R1) } - test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") { + test("parseAlgebraicMove preserves promotion to Queen") { val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val result = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White) result.isDefined should be (true) @@ -371,7 +371,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: } test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") { - // Exercises the promotion piece type branches in PgnParser.parseMovesText / toHistoryMove + // Exercises the promotion piece type branches in PgnParser.parseMovesText // White pawn advances via capture chain (a4xb5, b5xc6 e.p., c6-c7, c7xd8) and promotes val baseSequence = "1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=" for (piece, expected) <- List( @@ -384,7 +384,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true game.get.moves should not be empty - game.get.moves.last.promotionPiece shouldBe Some(expected) + game.get.moves.last.moveType shouldBe MoveType.Promotion(expected) } test("parseAlgebraicMove promotion with Rook through full PGN parse") { @@ -398,7 +398,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.promotionPiece shouldBe Some(PromotionPiece.Rook) + lastMove.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook) } test("parseAlgebraicMove promotion with Bishop through full PGN parse") { @@ -411,7 +411,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.promotionPiece shouldBe Some(PromotionPiece.Bishop) + lastMove.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop) } test("parseAlgebraicMove promotion with Knight through full PGN parse") { @@ -424,7 +424,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.promotionPiece shouldBe Some(PromotionPiece.Knight) + lastMove.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight) } test("extractPromotion returns None for invalid promotion letter") { diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala index d656db4..4edf132 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala @@ -1,7 +1,7 @@ package de.nowchess.io.pgn import de.nowchess.api.board.* -import de.nowchess.api.move.PromotionPiece +import de.nowchess.api.move.MoveType import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -70,7 +70,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers: """ PgnParser.validatePgn(pgn) match case Right(game) => - game.moves.last.castleSide shouldBe Some("Kingside") + game.moves.last.moveType shouldBe MoveType.CastleKingside case Left(err) => fail(s"Expected Right but got Left($err)") test("validatePgn: castling when not legal returns Left"): @@ -92,7 +92,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers: """ PgnParser.validatePgn(pgn) match case Right(game) => - game.moves.last.castleSide shouldBe Some("Queenside") + game.moves.last.moveType shouldBe MoveType.CastleQueenside case Left(err) => fail(s"Expected Right but got Left($err)") test("validatePgn: disambiguation with two rooks is accepted"): diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index 3d95679..1c665f6 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -85,7 +85,7 @@ object DefaultRules extends RuleSet: case Some(next) => board.pieceAt(next) match case None => loop(next, Move(from, next) :: acc) - case Some(p) if p.color != color => Move(from, next) :: acc + case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc case Some(_) => acc loop(from, Nil).reverse @@ -100,7 +100,8 @@ object DefaultRules extends RuleSet: from.offset(df, dr).flatMap { to => context.board.pieceAt(to) match case Some(p) if p.color == color => None - case _ => Some(Move(from, to)) + case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) + case None => Some(Move(from, to)) } } @@ -115,7 +116,8 @@ object DefaultRules extends RuleSet: from.offset(df, dr).flatMap { to => context.board.pieceAt(to) match case Some(p) if p.color == color => None - case _ => Some(Move(from, to)) + case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) + case None => Some(Move(from, to)) } } steps ++ castlingCandidates(context, from, color) @@ -220,17 +222,17 @@ object DefaultRules extends RuleSet: } } - def toMoves(dest: Square): List[Move] = + def toMoves(dest: Square, isCapture: Boolean): List[Move] = if dest.rank.ordinal == promoRank then List( PromotionPiece.Queen, PromotionPiece.Rook, PromotionPiece.Bishop, PromotionPiece.Knight ).map(pt => Move(from, dest, MoveType.Promotion(pt))) - else List(Move(from, dest)) + else List(Move(from, dest, MoveType.Normal(isCapture = isCapture))) val stepSquares = single.toList ++ double.toList - val stepMoves = stepSquares.flatMap(toMoves) - val captureMoves = diagonalCaptures.flatMap(toMoves) + val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false)) + val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true)) stepMoves ++ captureMoves ++ epCaptures // ── Check detection ──────────────────────────────────────────────── @@ -287,11 +289,14 @@ object DefaultRules extends RuleSet: case MoveType.CastleQueenside => applyCastle(board, color, kingside = false) case MoveType.EnPassant => applyEnPassant(board, move, color) case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp) - case MoveType.Normal => board.applyMove(move) + case MoveType.Normal(_) => board.applyMove(move) val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) val newEnPassantSquare = computeEnPassantSquare(board, move, color) - val isCapture = board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant + val isCapture = move.moveType match + case MoveType.Normal(capture) => capture + case MoveType.EnPassant => true + case _ => board.pieceAt(move.to).isDefined val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1