From 32dcdcf10f45ab705cc18c4d1ebb35851c301295 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 15:50:00 +0200 Subject: [PATCH] feat(notation): add pgn reading and writing --- Pgn.scala | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 Pgn.scala diff --git a/Pgn.scala b/Pgn.scala new file mode 100644 index 0000000..2aec798 --- /dev/null +++ b/Pgn.scala @@ -0,0 +1,109 @@ +package gambit + +final case class PgnGame( + tags: List[(String, String)], + startFen: String, + moves: List[Int], + result: String +) + +object Pgn: + private val resultTokens = Set("1-0", "0-1", "1/2-1/2", "*") + + def write( + startFen: String, + moves: List[Int], + result: String, + tags: List[(String, String)] = Nil + ): String = + val header = tagSection(startFen, result, tags) + val movetext = wrap(renderMoves(startFen, moves) :+ result) + s"$header\n$movetext\n" + + private def tagSection(startFen: String, result: String, extra: List[(String, String)]): String = + val provided = extra.map(_._1).toSet + val roster = List( + "Event" -> "gambit self-play", + "Site" -> "local", + "Date" -> "????.??.??", + "Round" -> "-", + "White" -> "gambit", + "Black" -> "gambit", + "Result" -> result + ).filterNot(pair => provided.contains(pair._1)) + val setup = + if startFen == Fen.startPosition then Nil + else List("SetUp" -> "1", "FEN" -> startFen) + val all = extra ++ roster ++ setup + all.map((k, v) => s"""[$k "$v"]""").mkString("\n") + + private def renderMoves(startFen: String, moves: List[Int]): List[String] = + val pos = Fen.parse(startFen) + val out = scala.collection.mutable.ListBuffer[String]() + var first = true + var index = 0 + while index < moves.length do + val m = moves(index) + if pos.sideToMove == Color.White then out += s"${pos.fullmove}." + else if first then out += s"${pos.fullmove}..." + out += San.toSan(pos, m) + pos.make(m) + first = false + index += 1 + out.toList + + private def wrap(tokens: List[String], width: Int = 80): String = + val sb = new StringBuilder + var lineLen = 0 + for token <- tokens do + val add = if lineLen == 0 then token.length else token.length + 1 + if lineLen > 0 && lineLen + add > width then + sb.append('\n') + sb.append(token) + lineLen = token.length + else + if lineLen > 0 then sb.append(' ') + sb.append(token) + lineLen += add + sb.toString + + def read(text: String): PgnGame = + val tags = parseTags(text) + val startFen = tags.find(_._1 == "FEN").map(_._2).getOrElse(Fen.startPosition) + val body = text.linesIterator.filterNot(_.trim.startsWith("[")).mkString(" ") + val cleaned = stripDecorations(body) + val tokens = cleaned.split("\\s+").filter(_.nonEmpty).toList + val pos = Fen.parse(startFen) + val moves = scala.collection.mutable.ListBuffer[Int]() + var result = "*" + for raw <- tokens do + val token = raw.replaceAll("^\\d+\\.+", "") + if token.isEmpty then () + else if resultTokens.contains(token) then result = token + else + val m = San.parse(pos, token) + if m != 0 then + moves += m + pos.make(m) + PgnGame(tags, startFen, moves.toList, result) + + private def parseTags(text: String): List[(String, String)] = + val pattern = """\[(\w+)\s+"([^"]*)"\]""".r + pattern.findAllMatchIn(text).map(m => (m.group(1), m.group(2))).toList + + private def stripDecorations(body: String): String = + val noComments = removePaired(body, '{', '}') + val noVariations = removePaired(noComments, '(', ')') + noVariations.replaceAll("\\$\\d+", " ") + + private def removePaired(s: String, open: Char, close: Char): String = + val sb = new StringBuilder + var depth = 0 + var i = 0 + while i < s.length do + val c = s.charAt(i) + if c == open then depth += 1 + else if c == close then (if depth > 0 then depth -= 1) + else if depth == 0 then sb.append(c) + i += 1 + sb.toString