From 1a4deee98aba7d3f9b70fe1a4ef8f3ebeee28afe Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 12:25:00 +0200 Subject: [PATCH] feat(notation): add san move formatting and parsing --- San.scala | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 San.scala diff --git a/San.scala b/San.scala new file mode 100644 index 0000000..4f7271c --- /dev/null +++ b/San.scala @@ -0,0 +1,113 @@ +package gambit + +object San: + private def pieceLetter(pieceType: Int): String = pieceType match + case Piece.Knight => "N" + case Piece.Bishop => "B" + case Piece.Rook => "R" + case Piece.Queen => "Q" + case Piece.King => "K" + case _ => "" + + private def letterToType(ch: Char): Int = ch match + case 'N' => Piece.Knight + case 'B' => Piece.Bishop + case 'R' => Piece.Rook + case 'Q' => Piece.Queen + case 'K' => Piece.King + case _ => Piece.Empty + + def toSan(pos: Position, move: Int): String = + val from = Move.from(move) + val to = Move.to(move) + val pieceType = Piece.typeOf(pos.board(from)) + val body = + if Move.isCastle(move) then + if Square.fileOf(to) == 6 then "O-O" else "O-O-O" + else if pieceType == Piece.Pawn then + val core = + if Move.isCapture(move) then s"${('a' + Square.fileOf(from)).toChar}x${Square.name(to)}" + else Square.name(to) + if Move.isPromotion(move) then s"$core=${pieceLetter(Move.promo(move))}" else core + else + val capture = if Move.isCapture(move) then "x" else "" + s"${pieceLetter(pieceType)}${disambiguation(pos, move, pieceType, from, to)}$capture${Square.name(to)}" + body + suffix(pos, move) + + private def disambiguation(pos: Position, move: Int, pieceType: Int, from: Int, to: Int): String = + val legal = new MoveList() + MoveGen.generateLegal(pos, legal) + var sameFile = false + var sameRank = false + var rival = false + var i = 0 + while i < legal.size do + val other = legal(i) + if other != move && Move.to(other) == to && Piece.typeOf(pos.board(Move.from(other))) == pieceType then + rival = true + if Square.fileOf(Move.from(other)) == Square.fileOf(from) then sameFile = true + if Square.rankOf(Move.from(other)) == Square.rankOf(from) then sameRank = true + i += 1 + if !rival then "" + else if !sameFile then s"${('a' + Square.fileOf(from)).toChar}" + else if !sameRank then s"${('1' + Square.rankOf(from)).toChar}" + else Square.name(from) + + private def suffix(pos: Position, move: Int): String = + val undo = pos.make(move) + val opponent = pos.sideToMove + val check = pos.inCheck(opponent) + val replies = new MoveList() + MoveGen.generateLegal(pos, replies) + val out = if check && replies.size == 0 then "#" else if check then "+" else "" + pos.unmake(move, undo) + out + + def parse(pos: Position, text: String): Int = + var s = text.trim + while s.nonEmpty && "+#!?".indexOf(s.last) >= 0 do s = s.dropRight(1) + if s == "O-O" || s == "0-0" then castleMove(pos, 6) + else if s == "O-O-O" || s == "0-0-0" then castleMove(pos, 2) + else + var promo = 0 + val eq = s.indexOf('=') + if eq >= 0 then + promo = letterToType(s(eq + 1)) + s = s.take(eq) + val isPawn = letterToType(s(0)) == Piece.Empty + val pieceType = if isPawn then Piece.Pawn else letterToType(s(0)) + var body = if isPawn then s else s.drop(1) + body = body.filter(_ != 'x') + val dest = Square.fromName(body.takeRight(2)) + val hint = body.dropRight(2) + var hintFile = -1 + var hintRank = -1 + for ch <- hint do + if ch >= 'a' && ch <= 'h' then hintFile = ch - 'a' + if ch >= '1' && ch <= '8' then hintRank = ch - '1' + matchMove(pos, pieceType, dest, promo, hintFile, hintRank) + + private def castleMove(pos: Position, toFile: Int): Int = + val legal = new MoveList() + MoveGen.generateLegal(pos, legal) + var i = 0 + while i < legal.size do + val m = legal(i) + if Move.isCastle(m) && Square.fileOf(Move.to(m)) == toFile then return m + i += 1 + 0 + + private def matchMove(pos: Position, pieceType: Int, dest: Int, promo: Int, hintFile: Int, hintRank: Int): Int = + val legal = new MoveList() + MoveGen.generateLegal(pos, legal) + var i = 0 + while i < legal.size do + val m = legal(i) + val from = Move.from(m) + if Piece.typeOf(pos.board(from)) == pieceType && Move.to(m) == dest + && (promo == 0 || Move.promo(m) == promo) + && (hintFile < 0 || Square.fileOf(from) == hintFile) + && (hintRank < 0 || Square.rankOf(from) == hintRank) + then return m + i += 1 + 0