fix(pgn): add SAN disambiguation and check/checkmate suffixes [NCS-42] (#56)
Build & Test (NowChessSystems) TeamCity build finished

Two bugs in move notation causing PGN import failures in LiChess:

1. Disambiguation: when two pieces of same type can reach same square,
   SAN requires file/rank/full-square prefix (e.g. "Ndf3" not "Nf3").
   Added disambiguate() in PgnExporter and disambiguatePiece() in
   GameEngine, both querying allLegalMoves to find competing pieces.

2. Check/checkmate suffix: "+" and "#" were never appended.
   PgnExporter now threads ctxAfter through moveToAlgebraic and
   calls DefaultRules.isCheck/isCheckmate. GameEngine passes
   PostMoveStatus to translateMoveToNotation for the same result.

Also removes dead notation code in executeMoveBody (result was never
used — not passed to MoveExecutedEvent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #56
This commit was merged in pull request #56.
This commit is contained in:
2026-06-02 11:24:27 +02:00
parent bc500e3e94
commit 2579539084
5 changed files with 180 additions and 23 deletions
@@ -31,7 +31,9 @@ object PgnExporter extends GameContextExport:
if moves.isEmpty then ""
else
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
val sanMoves = moves.zip(contexts).zip(contexts.tail).map { case ((move, ctxBefore), ctxAfter) =>
moveToAlgebraic(move, ctxBefore, ctxAfter)
}
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
@@ -48,9 +50,24 @@ object PgnExporter extends GameContextExport:
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
/** 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
private def disambiguate(from: Square, to: Square, pieceType: PieceType, ctx: GameContext): String =
val competitors = DefaultRules
.allLegalMoves(ctx)
.filter(m => m.to == to && m.from != from && ctx.board.pieceAt(m.from).exists(_.pieceType == pieceType))
if competitors.isEmpty then ""
else
val sameFile = competitors.exists(_.from.file == from.file)
val sameRank = competitors.exists(_.from.rank == from.rank)
if !sameFile then from.file.toString.toLowerCase
else if !sameRank then (from.rank.ordinal + 1).toString
else from.toString
private def moveToAlgebraic(move: Move, ctxBefore: GameContext, ctxAfter: GameContext): String =
val suffix =
if DefaultRules.isCheckmate(ctxAfter) then "#"
else if DefaultRules.isCheck(ctxAfter) then "+"
else ""
val base = 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}"
@@ -60,18 +77,19 @@ object PgnExporter extends GameContextExport:
case PromotionPiece.Rook => "=R"
case PromotionPiece.Bishop => "=B"
case PromotionPiece.Knight => "=N"
val isCapture = boardBefore.pieceAt(move.to).isDefined
val isCapture = ctxBefore.board.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
ctxBefore.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match
case PieceType.Pawn =>
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.Knight => s"N${disambiguate(move.from, move.to, PieceType.Knight, ctxBefore)}$capStr$dest"
case PieceType.Bishop => s"B${disambiguate(move.from, move.to, PieceType.Bishop, ctxBefore)}$capStr$dest"
case PieceType.Rook => s"R${disambiguate(move.from, move.to, PieceType.Rook, ctxBefore)}$capStr$dest"
case PieceType.Queen => s"Q${disambiguate(move.from, move.to, PieceType.Queen, ctxBefore)}$capStr$dest"
case PieceType.King => s"K$capStr$dest"
base + suffix
@@ -112,3 +112,38 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn should include("exf8=Q")
pawnCapturePgn should include("exd3")
quietPromotionPgn should include("e8=Q")
test("exportGame disambiguates when two knights can reach same square"):
// 1.Nf3 a6 2.d3 a5 3.Nfd2: d3 clears d2 so both Nb1 and Nf3 can reach d2; must emit "Nfd2"
val moves = List(
Move(sq("g1"), sq("f3")),
Move(sq("a7"), sq("a6")),
Move(sq("d2"), sq("d3")),
Move(sq("a6"), sq("a5")),
Move(sq("f3"), sq("d2")),
)
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include("Nfd2")
test("exportGame appends + after move that gives check"):
// 1.e4 e5 2.Qh5 Nc6 3.Qxf7+ — queen captures f7, gives check to black king on e8
val moves = List(
Move(sq("e2"), sq("e4")),
Move(sq("e7"), sq("e5")),
Move(sq("d1"), sq("h5")),
Move(sq("b8"), sq("c6")),
Move(sq("h5"), sq("f7"), MoveType.Normal(isCapture = true)),
)
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include("Qxf7+")
test("exportGame appends # after checkmate move"):
// Fool's mate: 1.f3 e5 2.g4 Qh4#
val moves = List(
Move(sq("f2"), sq("f3")),
Move(sq("e7"), sq("e5")),
Move(sq("g2"), sq("g4")),
Move(sq("d8"), sq("h4")),
)
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
pgn should include("Qh4#")