From 58a962cf98b0a2abf5a58e222b53e43be0b11971 Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 28 Mar 2026 18:48:08 +0100 Subject: [PATCH] feat: add PGN exporter for game notation Co-Authored-By: Claude Sonnet 4.6 --- .../nowchess/chess/notation/PgnExporter.scala | 35 ++++++++++++++ .../chess/notation/PgnExporterTest.scala | 48 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala new file mode 100644 index 0000000..8eb4d1c --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala @@ -0,0 +1,35 @@ +package de.nowchess.chess.notation + +import de.nowchess.api.board.* +import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove} + +object PgnExporter: + + /** Export a game with headers and history to PGN format. */ + def exportGame(headers: Map[String, String], history: GameHistory): String = + val headerLines = headers.map { case (key, value) => + s"""[$key "$value"]""" + }.mkString("\n") + + val moveText = if history.moves.isEmpty then "" + else + val groupedMoves = history.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 => moveToAlgebraic(p._1)).getOrElse("") + val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("") + if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" + else s"$moveNum. $whiteMoveStr $blackMoveStr" + + moveLines.mkString(" ") + " *" + + if headerLines.isEmpty then moveText + else if moveText.isEmpty then headerLines + else s"$headerLines\n\n$moveText" + + /** Convert a HistoryMove to algebraic notation. */ + private def moveToAlgebraic(move: HistoryMove): String = + move.castleSide match + case Some(CastleSide.Kingside) => "O-O" + case Some(CastleSide.Queenside) => "O-O-O" + case None => s"${move.from}${move.to}" diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala new file mode 100644 index 0000000..1869141 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala @@ -0,0 +1,48 @@ +package de.nowchess.chess.notation + +import de.nowchess.api.board.* +import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class PgnExporterTest extends AnyFunSuite with Matchers: + + test("export empty game") { + val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") + val history = GameHistory.empty + val pgn = PgnExporter.exportGame(headers, history) + + pgn.contains("[Event \"Test\"]") shouldBe true + pgn.contains("[White \"A\"]") shouldBe true + pgn.contains("[Black \"B\"]") shouldBe true + } + + test("export single move") { + val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") + val history = GameHistory() + .addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) + val pgn = PgnExporter.exportGame(headers, history) + + pgn.contains("1. e2e4") shouldBe true + } + + test("export castling") { + val headers = Map("Event" -> "Test") + val history = GameHistory() + .addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some(CastleSide.Kingside))) + val pgn = PgnExporter.exportGame(headers, history) + + pgn.contains("O-O") shouldBe true + } + + test("export game sequence") { + val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0") + val history = GameHistory() + .addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) + .addMove(HistoryMove(Square(File.C, Rank.R7), Square(File.C, Rank.R5), None)) + .addMove(HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None)) + val pgn = PgnExporter.exportGame(headers, history) + + pgn.contains("1. e2e4 c7c5") shouldBe true + pgn.contains("2. g1f3") shouldBe true + }