chessIO-0.8.0.0: src/Game/Chess/SAN.hs
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-|
Module : Game.Chess.SAN
Description : Standard Algebraic Notation
Copyright : (c) Mario Lang, 2021
License : BSD3
Maintainer : mlang@blind.guru
Stability : experimental
Parsers and printers for [Algebraic Notation](https://en.wikipedia.org/wiki/Algebraic_notation_%28chess%29).
-}
module Game.Chess.SAN (
-- * Conversion
fromSAN, toSAN, unsafeToSAN
-- * Parsers
, SANToken, strictSAN, relaxedSAN
-- * Utilities
, varToSAN
) where
import Control.Applicative (Applicative (liftA2))
import Control.Arrow ((&&&))
import Control.Lens (view)
import Control.Lens.Iso (from)
import Data.Bifunctor (first)
import qualified Data.ByteString as Strict (ByteString)
import qualified Data.ByteString.Lazy as Lazy (ByteString)
import Data.Char (ord)
import Data.Functor (($>))
import Data.List (sortOn)
import Data.List.Extra (chunksOf)
import Data.Maybe (fromJust)
import Data.MonoTraversable (Element, MonoFoldable (otoList))
import Data.Ord (Down (..))
import Data.Proxy (Proxy (..))
import Data.String (IsString (fromString))
import qualified Data.Text as Strict (Text)
import qualified Data.Text.Lazy as Lazy (Text)
import Data.Traversable (mapAccumL)
import Data.Void (Void)
import Data.Word (Word8)
import GHC.Stack (HasCallStack)
import Game.Chess.Internal (Castle (Kingside, Queenside),
Color (Black, White),
PieceType,
pattern Pawn,
pattern Knight,
pattern Bishop,
pattern Rook,
pattern Queen,
pattern King,
Ply,
Position (color, moveNumber),
bKscm, bQscm, canCastleKingside,
canCastleQueenside, doPly, inCheck,
isCapture, legalPlies, pieceAt,
plySource, plyTarget, promoteTo,
unpack, unsafeDoPly, wKscm, wQscm)
import Game.Chess.Internal.Square
import Text.Megaparsec (MonadParsec (token, try), Parsec,
Stream, Token, Tokens,
TraversableStream, VisualStream,
choice, chunk, chunkLength, empty,
errorBundlePretty, option,
optional, parse, (<?>), (<|>))
type Parser s = Parsec Void s
castling :: (Stream s, IsString (Tokens s))
=> Position -> Parser s Ply
castling pos
| ccks && ccqs = queenside <|> kingside
| ccks = kingside
| ccqs = queenside
| otherwise = empty
where
ccks = canCastleKingside pos
ccqs = canCastleQueenside pos
kingside = chunk "O-O" $> castleMove Kingside
queenside = chunk "O-O-O" $> castleMove Queenside
castleMove Kingside | color pos == White = wKscm
| otherwise = bKscm
castleMove Queenside | color pos == White = wQscm
| otherwise = bQscm
data From = F File
| R Rank
| RF Square
deriving (Eq, Show)
data SANStatus = Check | Checkmate deriving (Eq, Read, Show)
class SANToken a where
sanPieceToken :: a -> Maybe PieceType
fileToken :: a -> Maybe Int
rankToken :: a -> Maybe Int
promotionPieceToken :: a -> Maybe PieceType
statusToken :: a -> Maybe SANStatus
sanPiece :: (Stream s, SANToken (Token s)) => Parser s PieceType
sanPiece = token sanPieceToken mempty <?> "piece"
fileP :: (Stream s, SANToken (Token s)) => Parser s File
fileP = mkFile <$> token fileToken mempty <?> "file"
rankP :: (Stream s, SANToken (Token s)) => Parser s Rank
rankP = mkRank <$> token rankToken mempty <?> "rank"
squareP :: (Stream s, SANToken (Token s)) => Parser s Square
squareP = liftA2 (flip . curry $ view (from rankFile)) fileP rankP <?> "square"
promotionPiece :: (Stream s, SANToken (Token s)) => Parser s PieceType
promotionPiece = token promotionPieceToken mempty <?> "Q, R, B, N"
sanStatus :: (Stream s, SANToken (Token s)) => Parser s SANStatus
sanStatus = token statusToken mempty <?> "+, #"
instance SANToken Char where
sanPieceToken = \case
'N' -> Just Knight
'B' -> Just Bishop
'R' -> Just Rook
'Q' -> Just Queen
'K' -> Just King
_ -> Nothing
fileToken c | c >= 'a' && c <= 'h' = Just $ ord c - ord 'a'
| otherwise = Nothing
rankToken c | c >= '1' && c <= '8' = Just $ ord c - ord '1'
| otherwise = Nothing
promotionPieceToken = \case
'N' -> Just Knight
'B' -> Just Bishop
'R' -> Just Rook
'Q' -> Just Queen
_ -> Nothing
statusToken = \case
'+' -> Just Check
'#' -> Just Checkmate
_ -> Nothing
instance SANToken Word8 where
sanPieceToken = \case
78 -> Just Knight
66 -> Just Bishop
82 -> Just Rook
81 -> Just Queen
75 -> Just King
_ -> Nothing
rankToken c | c >= 49 && c <= 56 = Just . fromIntegral $ c - 49
| otherwise = Nothing
fileToken c | c >= 97 && c <= 104 = Just . fromIntegral $ c - 97
| otherwise = Nothing
promotionPieceToken = \case
78 -> Just Knight
66 -> Just Bishop
82 -> Just Rook
81 -> Just Queen
_ -> Nothing
statusToken = \case
43 -> Just Check
35 -> Just Checkmate
_ -> Nothing
strictSAN :: forall s. (Stream s, SANToken (Token s), IsString (Tokens s))
=> Position -> Parser s Ply
strictSAN pos = case legalPlies pos of
[] -> fail "No legal moves in this position"
ms -> (castling pos <|> normal ms) >>= checkStatus
where
normal ms = do
p <- sanPiece <|> pure Pawn
case filter (pieceFrom p) ms of
[] -> fail $ show (color pos) <> " has no " <> show p <> " which could be moved"
ms' -> target p ms'
pieceFrom p (plySource -> src) = p == snd (fromJust (pieceAt pos src))
target p ms = coords p ms >>= \m@(plyTarget -> to) -> case p of
Pawn | lastRank to -> promoteTo m <$> promotion
_ -> pure m
coords p ms = choice $ fmap (uncurry (<$) . fmap chunk) $
sortOn (Down . chunkLength (Proxy :: Proxy s) . snd) $
(\m -> (m, sanCoords pos (p,ms) m)) <$> ms
promotion = chunk "=" *> promotionPiece
lastRank (rank -> r) = r == Rank1 || r == Rank8
checkStatus m
| inCheck (color nextPos) nextPos && null (legalPlies nextPos)
= chunk "#" $> m
| inCheck (color nextPos) nextPos
= chunk "+" $> m
| otherwise
= pure m
where
nextPos = unsafeDoPly pos m
relaxedSAN :: (Stream s, SANToken (Token s), IsString (Tokens s))
=> Position -> Parser s Ply
relaxedSAN pos = (castling pos <|> normal) <* optional sanStatus where
normal = do
pc <- sanPiece <|> pure Pawn
(src, _, dst) <- conv <$> location
prm <- optional $ optional (chunk "=") *> promotionPiece
case possible pc src dst prm of
[m] -> pure m
[] -> fail "Illegal move"
_ -> fail "Ambiguous move"
conv (Nothing, Nothing, cap, to) = (Nothing, cap, to)
conv (Just f, Nothing, cap, to) = (Just (F f), cap, to)
conv (Nothing, Just r, cap, to) = (Just (R r), cap, to)
conv (Just f, Just r, cap, to) = (Just (RF (view (from rankFile) (r, f))), cap, to)
location = try ((,Nothing,,) <$> (Just <$> fileP) <*> capture <*> squareP)
<|> try ((Nothing,,,) <$> (Just <$> rankP) <*> capture <*> squareP)
<|> try ((,,,) <$> (Just <$> fileP) <*> (Just <$> rankP)
<*> capture <*> squareP)
<|> (Nothing,Nothing,,) <$> capture <*> squareP
capture = option False $ chunk "x" $> True
ms = legalPlies pos
possible pc src dst prm = filter (f src) ms where
f (Just (RF sq)) (unpack -> (src', dst', prm')) =
pAt src' == pc && src' == sq && dst' == dst && prm' == prm
f (Just (F ff)) (unpack -> (src', dst', prm')) =
pAt src' == pc && file src' == ff && dst == dst' && prm == prm'
f (Just (R fr)) (unpack -> (src', dst', prm')) =
pAt src' == pc && rank src' == fr && dst == dst' && prm == prm'
f Nothing (unpack -> (src', dst', prm')) =
pAt src' == pc && dst == dst' && prm == prm'
pAt = snd . fromJust . pieceAt pos
fromSAN :: (VisualStream s, TraversableStream s, SANToken (Token s), IsString (Tokens s))
=> Position -> s -> Either String Ply
fromSAN pos = first errorBundlePretty . parse (relaxedSAN pos) ""
toSAN :: (HasCallStack, IsString s) => Position -> Ply -> s
toSAN pos m
| m `elem` legalPlies pos = fromString $ unsafeToSAN pos m
| otherwise = error "Game.Chess.toSAN: Illegal move"
varToSAN :: (MonoFoldable variation, Element variation ~ Ply, IsString string)
=> Position -> variation -> string
varToSAN p = fromString . go p . otoList where
go _ [] = ""
go pos plies
| color pos == Black && length plies == 1
= show (moveNumber pos) <> "..." <> toSAN pos (head plies)
| color pos == Black
= show (moveNumber pos) <> "..." <> toSAN pos (head plies) <> " " <> fromWhite (doPly pos (head plies)) (tail plies)
| otherwise
= fromWhite pos plies
fromWhite pos = unwords . concat
. zipWith f [moveNumber pos ..] . chunksOf 2 . snd
. mapAccumL (curry (uncurry doPly &&& uncurry toSAN)) pos
f n (x:xs) = (show n <> "." <> x):xs
f _ [] = []
sanCoords :: IsString s => Position -> (PieceType, [Ply]) -> Ply -> s
sanCoords pos (pc,lms) m@(unpack -> (src, dst, _)) =
fromString $ source <> target
where
capture = isCapture pos m
source
| pc == Pawn && capture = [fileChar src]
| pc == Pawn = []
| length ms == 1 = []
| length (filter fEq ms) == 1 = [fileChar src]
| length (filter rEq ms) == 1 = [rankChar src]
| otherwise = toCoord src
target
| capture = "x" <> toCoord dst
| otherwise = toCoord dst
ms = filter ((dst ==) . plyTarget) lms
fEq (file . plySource -> fl) = fl == srcFile
rEq (rank . plySource -> rnk) = rnk == srcRank
(srcRank, srcFile) = view rankFile src
unsafeToSAN :: Position -> Ply -> String
unsafeToSAN pos m@(unpack -> (src, dst, promo)) =
moveStr <> status
where
moveStr = case piece of
Pawn | capture -> fileChar src : target <> promotion
| otherwise -> target <> promotion
King | color pos == White && m == wKscm -> "O-O"
| color pos == White && m == wQscm -> "O-O-O"
| color pos == Black && m == bKscm -> "O-O"
| color pos == Black && m == bQscm -> "O-O-O"
| otherwise -> 'K' : target
Knight -> 'N' : source <> target
Bishop -> 'B' : source <> target
Rook -> 'R' : source <> target
Queen -> 'Q' : source <> target
piece = fromJust $ snd <$> pieceAt pos src
capture = isCapture pos m
source
| length ms == 1 = []
| length (filter fEq ms) == 1 = [fileChar src]
| length (filter rEq ms) == 1 = [rankChar src]
| otherwise = toCoord src
target
| capture = "x" <> toCoord dst
| otherwise = toCoord dst
promotion = case promo of
Just Knight -> "N"
Just Bishop -> "B"
Just Rook -> "R"
Just Queen -> "Q"
_ -> ""
status | inCheck (color nextPos) nextPos && null (legalPlies nextPos) = "#"
| inCheck (color nextPos) nextPos = "+"
| otherwise = ""
nextPos = unsafeDoPly pos m
ms = filter movesTo $ legalPlies pos
movesTo (unpack -> (src', dst', _)) =
fmap snd (pieceAt pos src') == Just piece && dst' == dst
fEq (file . plySource -> thisFile) = thisFile == srcFile
rEq (rank . plySource -> thisRank) = thisRank == srcRank
(srcRank, srcFile) = view rankFile src
{-# SPECIALISE relaxedSAN :: Position -> Parser Strict.ByteString Ply #-}
{-# SPECIALISE relaxedSAN :: Position -> Parser Lazy.ByteString Ply #-}
{-# SPECIALISE relaxedSAN :: Position -> Parser Strict.Text Ply #-}
{-# SPECIALISE relaxedSAN :: Position -> Parser Lazy.Text Ply #-}
{-# SPECIALISE relaxedSAN :: Position -> Parser String Ply #-}
{-# SPECIALISE strictSAN :: Position -> Parser Strict.ByteString Ply #-}
{-# SPECIALISE strictSAN :: Position -> Parser Lazy.ByteString Ply #-}
{-# SPECIALISE strictSAN :: Position -> Parser Strict.Text Ply #-}
{-# SPECIALISE strictSAN :: Position -> Parser Lazy.Text Ply #-}
{-# SPECIALISE strictSAN :: Position -> Parser String Ply #-}