Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 22 additions & 15 deletions core/src/main/scala/format/pgn/Parser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,6 @@ object Parser:
val fileMap = File.all.mapBy(_.char)
val rankMap = Rank.all.mapBy(_.char)

val castleQSide = List("O-O-O", "o-o-o", "0-0-0", "O‑O‑O", "o‑o‑o", "0‑0‑0", "O–O–O", "o–o–o", "0–0–0")
val qCastle: P[Side] = P.stringIn(castleQSide).as(QueenSide)

val castleKSide = List("O-O", "o-o", "0-0", "O‑O", "o‑o", "0‑0", "O–O", "o–o", "0–0")
val kCastle: P[Side] = P.stringIn(castleKSide).as(KingSide)

val glyph: P[Glyph] = mapParser(Glyph.MoveAssessment.all.mapBy(_.symbol), "glyph")
val glyphs = glyph.rep0.map(Glyphs.fromList)

Expand Down Expand Up @@ -165,11 +159,6 @@ object Parser:
case ((ro, ca), de) =>
Std(dest = de, role = ro, capture = ca)

// B@g5
val drop: P[Drop] = ((role <* P.char('@')) ~ dest).map(Drop(_, _))

val pawnDrop: P[Drop] = (P.char('@') *> dest).map(Drop(Pawn, _))

// optional e.p.
val optionalEnPassant = (R.wsp.rep0.soft ~ P.stringIn(List("e.p.", "ep"))).void.?

Expand All @@ -193,12 +182,30 @@ object Parser:
val glyphs = glyphs1.merge(glyphs2.merge(glyphs3))
Metas(check, checkmate, comments.cleanUp, glyphs)

val castle: P[San] = (qCastle | kCastle).map(Castle(_))
val standard: P[Std] =
P.oneOf:
(pawn :: disambiguated :: ambigous :: Nil).map:
_.backtrack.withString.map((san, raw) => san.copy(rawString = raw.some))

val castleQSide = List("O-O-O", "o-o-o", "0-0-0", "O‑O‑O", "o‑o‑o", "0‑0‑0", "O–O–O", "o–o–o", "0–0–0")
val qCastle: P[Side] = P.stringIn(castleQSide).as(QueenSide)

val castleKSide = List("O-O", "o-o", "0-0", "O‑O", "o‑o", "0‑0", "O–O", "o–o", "0–0")
val kCastle: P[Side] = P.stringIn(castleKSide).as(KingSide)

val castle: P[San] = (qCastle | kCastle).withString.map((side, raw) => Castle(side, raw.some))

// B@g5
val pieceDrop: P[Drop] = ((role <* P.char('@')) ~ dest).map(Drop(_, _))

val pawnDrop: P[Drop] = (P.char('@') *> dest).map(Drop(Pawn, _))

val standard: P[San] =
P.oneOf((pawn :: disambiguated :: ambigous :: drop :: pawnDrop :: Nil).map(_.backtrack))
val drop: P[Drop] =
P.oneOf:
(pieceDrop :: pawnDrop :: Nil).map:
_.backtrack.withString.map((san, raw) => san.copy(rawString = raw.some))

val san: P[San] = (castle | standard).withContext("Invalid chess move")
val san: P[San] = (castle | standard | drop).withContext("Invalid chess move")

def mapParser[A](pairMap: Map[String, A], name: String): P[A] =
P.stringIn(pairMap.keySet).map(pairMap.apply) | P.failWith(name + " not found")
Expand Down
13 changes: 10 additions & 3 deletions core/src/main/scala/format/pgn/Reader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@ object Reader:
.map(moves => makeReplay(makeGame(tags), op(moves)))

private def makeReplay(game: Game, sans: Sans): Result =
sans.value
.foldM(Replay(game)): (replay, san) =>
san(replay.state.situation).bimap((replay, _), replay.addMove(_))
sans.value.zipWithIndex
.foldM(Replay(game)) { case (replay, (san, index)) =>
san(replay.state.situation).bimap(_ => (replay, makeError(index, game.ply, san)), replay.addMove(_))
}
.match
case Left(replay, err) => Result.Incomplete(replay, err)
case Right(replay) => Result.Complete(replay)

inline def makeError(index: Int, startedPly: Ply, san: San): ErrorStr =
val ply = startedPly + index
val moveAt = ply.fullMoveNumber.value
val move = san.rawString.getOrElse(san.toString)
ErrorStr(s"Cannot play $move at move $moveAt by ${ply.turn.name}")

private def makeGame(tags: Tags) =
val g = Game(variantOption = tags.variant, fen = tags.fen)
g.copy(startedAtPly = g.ply, clock = tags.timeControl.flatMap(_.toClockConfig).map(Clock.apply))
8 changes: 5 additions & 3 deletions core/src/main/scala/format/pgn/parsingModel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,16 @@ case class ParsedMainline[A](initialPosition: InitialComments, tags: Tags, sans:
// Standard Algebraic Notation
sealed trait San:
def apply(situation: Situation): Either[ErrorStr, MoveOrDrop]
def rawString: Option[String] = None

case class Std(
dest: Square,
role: Role,
capture: Boolean = false,
file: Option[File] = None,
rank: Option[Rank] = None,
promotion: Option[PromotableRole] = None
promotion: Option[PromotableRole] = None,
override val rawString: Option[String] = None
) extends San:

def apply(situation: Situation): Either[ErrorStr, chess.Move] =
Expand All @@ -88,12 +90,12 @@ case class Std(

private inline def compare[A](a: Option[A], b: A) = a.fold(true)(b ==)

case class Drop(role: Role, square: Square) extends San:
case class Drop(role: Role, square: Square, override val rawString: Option[String] = None) extends San:

def apply(situation: Situation): Either[ErrorStr, chess.Drop] =
situation.drop(role, square)

case class Castle(side: Side) extends San:
case class Castle(side: Side, override val rawString: Option[String] = None) extends San:

def apply(situation: Situation): Either[ErrorStr, chess.Move] =

Expand Down
19 changes: 11 additions & 8 deletions test-kit/src/test/scala/format/pgn/ParserTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class ParserTest extends ChessTest:
test("promotion check as a queen"):
parse("b8=Q ").assertRight: parsed =>
parsed.mainline.headOption.assertSome: san =>
assertEquals(san, Std(Square.B8, Pawn, promotion = Option(Queen)))
assertEquals(san, Std(Square.B8, Pawn, promotion = Option(Queen), rawString = "b8=Q".some))

test("promotion check as a rook"):
parse("b8=R ").assertRight: parsed =>
Expand Down Expand Up @@ -88,24 +88,27 @@ class ParserTest extends ChessTest:
test("glyphs"):

parseMove("b8=B ").assertRight: node =>
assertEquals(node.value.san, Std(Square.B8, Pawn, promotion = Option(Bishop)))
assertEquals(node.value.san, Std(Square.B8, Pawn, promotion = Option(Bishop), rawString = "b8=B".some))

parseMove("1. e4").assertRight: node =>
assertEquals(node.value.san, Std(Square.E4, Pawn))
assertEquals(node.value.san, Std(Square.E4, Pawn, rawString = "e4".some))

parseMove("e4").assertRight: node =>
assertEquals(node.value.san, Std(Square.E4, Pawn))
assertEquals(node.value.san, Std(Square.E4, Pawn, rawString = "e4".some))

parseMove("e4!").assertRight: node =>
assertEquals(node.value.san, Std(Square.E4, Pawn))
assertEquals(node.value.san, Std(Square.E4, Pawn, rawString = "e4".some))
assertEquals(node.value.metas.glyphs, Glyphs(Glyph.MoveAssessment.good.some, None, Nil))

parseMove("Ne7g6+?!").assertRight: node =>
assertEquals(node.value.san, Std(Square.G6, Knight, false, Some(File.E), Some(Rank.Seventh)))
assertEquals(
node.value.san,
Std(Square.G6, Knight, false, Some(File.E), Some(Rank.Seventh), rawString = "Ne7g6".some)
)
assertEquals(node.value.metas.glyphs, Glyphs(Glyph.MoveAssessment.dubious.some, None, Nil))

parseMove("P@e4?!").assertRight: node =>
assertEquals(node.value.san, Drop(Pawn, Square.E4))
assertEquals(node.value.san, Drop(Pawn, Square.E4, rawString = "P@e4".some))
assertEquals(node.value.metas.glyphs, Glyphs(Glyph.MoveAssessment.dubious.some, None, Nil))

test("nags"):
Expand Down Expand Up @@ -154,7 +157,7 @@ class ParserTest extends ChessTest:
Parser
.san(sanStr)
.assertRight: san =>
assertEquals(san, Std(Square.E4, Pawn))
assertEquals(san, Std(Square.E4, Pawn, rawString = "e4".some))

test("mainlineWithMetas == full.mainlineWithMetas"):
verifyMainlineWithMetas(raws)
Expand Down
20 changes: 19 additions & 1 deletion test-kit/src/test/scala/format/pgn/PgnRenderTest.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package chess
package format.pgn

import cats.syntax.all.*
import monocle.syntax.all.*

import scala.language.implicitConversions

class PgnRenderTest extends ChessTest:
Expand All @@ -14,6 +17,21 @@ class PgnRenderTest extends ChessTest:
def cleanTags: ParsedPgn =
pgn.copy(tags = Tags.empty)

def cleanRawString: ParsedPgn =
pgn.copy(tree = pgn.tree.map(removeRawString))

def removeRawString(san: San): San =
san match
case std: Std => std.copy(rawString = None)
case drop: Drop => drop.copy(rawString = None)
case castle: Castle => castle.copy(rawString = None)

def removeRawString(node: Node[PgnNodeData]): Node[PgnNodeData] =
node
.focus(_.value.san)
.modify(removeRawString)
.map(_.focus(_.san).modify(removeRawString))

lazy val pgns = List(
pgn2,
recentChessCom,
Expand All @@ -36,7 +54,7 @@ class PgnRenderTest extends ChessTest:
(pgns ++ wcc2023).foreach: x =>
val pgn = Parser.full(x).get
val output = Parser.full(pgn.toPgn.render).get
assertEquals(output.cleanTags, pgn.cleanTags)
assertEquals(output.cleanTags.cleanRawString, pgn.cleanTags.cleanRawString)

test("pgn round trip tests compare Pgn"):
List(pgn1, pgn3, pgn4).foreach: x =>
Expand Down
20 changes: 20 additions & 0 deletions test-kit/src/test/scala/format/pgn/ReaderTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,23 @@ class ReaderTest extends ChessTest:
.lift(42)
.assertSome: m =>
assertEquals(m.toUci.uci, "e7f8q")

/*============== Error Messages ==============*/

test("simple error message"):
val pgn = PgnStr("1.e6")
Reader
.full(pgn)
.assertRight:
case Incomplete(replay, error) =>
assertEquals(error, ErrorStr("Cannot play e6 at move 1 by white"))

test("more complicated error message"):
val pgn = PgnStr(
"e3 Nc6 d4 Nf6 c3 e5 dxe5 Nxe5 Bb5 a6 Ba4 b5 Bb3 d5 e4 dxe4 f4 Qxd1+ Kxd1 Nd3 Be3 Ng4 Bd4 Ngf2+ Bxf2 Nxf2+ Ke1 Nxh1 Bd5 Ra7 Bc6+ Kd8 Bxe4 Bd6 g3 Re8 Nd2 f5 Ne2 fxe4 Kf1 e3 Kg2 exd2 Rxh1 Bb7+ Kf2 Bg3+ Kf3 d1=Q#"
)
Reader
.full(pgn)
.assertRight:
case Incomplete(replay, error) =>
assertEquals(error, ErrorStr("Cannot play Bg3 at move 24 by black"))