packages feed

poker-base-0.1.0.0: src/Poker/Cards.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Card types and operators.
module Poker.Cards
  ( Rank (..),
    allRanks,
    Suit (..),
    allSuits,
    suitToUnicode,
    suitFromUnicode,
    Card (..),
    allCards,
    Hole (..),
    mkHole,
    allHoles,
    ShapedHole (..),
    mkPair,
    mkOffsuit,
    mkSuited,
    allShapedHoles,
    holeToShapedHole,
    Deck,
    freshDeck,
    unsafeDeck,
    shapedHoleToHoles,
    rankToChr,
    chrToRank,
    suitToChr,
    chrToSuit,
    cardToShortTxt,
    cardFromShortTxt,
    shapedHoleToShortTxt,
    holeToShortTxt,
    unsafeOffsuit,
    unsafeSuited,
    unsafeHole,
    holeFromShortTxt,
  )
where

#if MIN_VERSION_prettyprinter(1,7,0)
import Prettyprinter
import Prettyprinter.Internal ( unsafeTextWithoutNewlines, Doc(Char) )
#else
import Data.Text.Prettyprint.Doc
#endif
import Control.Applicative
import Control.Monad
import Data.Bifunctor (Bifunctor (second))
import Data.Maybe
import Data.String (IsString (fromString))
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import Poker.Utils
import Test.QuickCheck (Arbitrary (arbitrary), elements)
import Test.QuickCheck.Arbitrary.Generic (GenericArbitrary (..))

-- $setup
-- >>> import Test.QuickCheck
-- >>> import Data.Maybe
-- >>> import Prettyprinter

-- | The 'Rank' of a playing 'Card'
data Rank
  = Two
  | Three
  | Four
  | Five
  | Six
  | Seven
  | Eight
  | Nine
  | Ten
  | Jack
  | Queen
  | King
  | Ace
  deriving (Enum, Bounded, Eq, Ord, Show, Read, Generic)
  deriving (Arbitrary) via GenericArbitrary Rank

-- | >>> pretty <$> allRanks
-- [2,3,4,5,6,7,8,9,T,J,Q,K,A]
instance Pretty Rank where
  pretty = unsafeTextWithoutNewlines . T.singleton . rankToChr

-- | >>> allRanks
-- [Two,Three,Four,Five,Six,Seven,Eight,Nine,Ten,Jack,Queen,King,Ace]
allRanks :: [Rank]
allRanks = enumerate @Rank

-- | >>> rankToChr <$> allRanks
-- "23456789TJQKA"
rankToChr :: Rank -> Char
rankToChr = \case
  Two -> '2'
  Three -> '3'
  Four -> '4'
  Five -> '5'
  Six -> '6'
  Seven -> '7'
  Eight -> '8'
  Nine -> '9'
  Ten -> 'T'
  Jack -> 'J'
  Queen -> 'Q'
  King -> 'K'
  Ace -> 'A'

-- | >>> map (fromJust . chrToRank) "23456789TJQKA"
-- [Two,Three,Four,Five,Six,Seven,Eight,Nine,Ten,Jack,Queen,King,Ace]
-- >>> chrToRank 'x'
-- Nothing
--
-- prop> \r -> chrToRank (rankToChr r) == Just r
chrToRank :: Char -> Maybe Rank
chrToRank = \case
  '2' -> pure Two
  '3' -> pure Three
  '4' -> pure Four
  '5' -> pure Five
  '6' -> pure Six
  '7' -> pure Seven
  '8' -> pure Eight
  '9' -> pure Nine
  'T' -> pure Ten
  'J' -> pure Jack
  'Q' -> pure Queen
  'K' -> pure King
  'A' -> pure Ace
  _ -> Nothing

-- | The 'Suit' of a playing 'Card'
data Suit = Club | Diamond | Heart | Spade
  deriving (Enum, Bounded, Eq, Ord, Show, Read, Generic)
  deriving (Arbitrary) via GenericArbitrary Suit

-- | >>> pretty allSuits
-- [c, d, h, s]
instance Pretty Suit where
  pretty = Char . suitToChr

-- | >>> allSuits
-- [Club,Diamond,Heart,Spade]
allSuits :: [Suit]
allSuits = enumerate @Suit

-- | >>> suitToChr <$> allSuits
-- "cdhs"
suitToChr :: Suit -> Char
suitToChr = \case
  Club -> 'c'
  Diamond -> 'd'
  Heart -> 'h'
  Spade -> 's'

-- | >>> map (fromJust . chrToSuit) "cdhs"
-- [Club,Diamond,Heart,Spade]
-- >>> chrToSuit 'x'
-- Nothing
--
-- prop> \s -> chrToSuit (suitToChr s) == Just s
chrToSuit :: Char -> Maybe Suit
chrToSuit = \case
  'c' -> pure Club
  'd' -> pure Diamond
  'h' -> pure Heart
  's' -> pure Spade
  _ -> Nothing

-- | >>> suitToUnicode <$> [Club, Diamond, Heart, Spade]
-- "\9827\9830\9829\9824"
-- >>> suitFromUnicode . suitToUnicode <$> [Club, Diamond, Heart, Spade]
-- [Just Club,Just Diamond,Just Heart,Just Spade]
suitToUnicode :: Suit -> Char
suitToUnicode = \case
  Club -> '♣'
  Diamond -> '♦'
  Heart -> '♥'
  Spade -> '♠'

-- | >>> suitFromUnicode <$> ['♣', '♦', '♥', '♠']
-- [Just Club,Just Diamond,Just Heart,Just Spade]
--
-- prop> \s -> suitFromUnicode (suitToUnicode s) == Just s
suitFromUnicode :: Char -> Maybe Suit
suitFromUnicode = \case
  '♣' -> Just Club
  '♦' -> Just Diamond
  '♥' -> Just Heart
  '♠' -> Just Spade
  _ -> Nothing

-- | Representation of a playing card.
data Card = Card
  { rank :: !Rank,
    suit :: !Suit
  }
  deriving (Eq, Ord, Show, Read, Generic)
  deriving (Arbitrary) via GenericArbitrary Card

-- | >>> pretty ("Ac" :: Card)
-- Ac
instance Pretty Card where
  pretty c = unsafeTextWithoutNewlines $ cardToShortTxt c

instance IsString Card where
  fromString = fromJust . cardFromShortTxt . T.pack

-- | All cards in a 'Deck'
--
-- >>> length allCards
-- 52
allCards :: [Card]
allCards = liftA2 Card allRanks allSuits

-- | >>> cardToShortTxt "Ac"
-- "Ac"
cardToShortTxt :: Card -> Text
cardToShortTxt (Card r s) = T.pack [rankToChr r, suitToChr s]

-- | >>> cardFromShortTxt "Ac"
-- Just (Card {rank = Ace, suit = Club})
--
-- prop> \c -> cardFromShortTxt (cardToShortTxt c) == Just c
cardFromShortTxt :: Text -> Maybe Card
cardFromShortTxt cs = case second T.uncons <$> T.uncons cs of
  Just (r, Just (s, T.null -> True)) -> Card <$> chrToRank r <*> chrToSuit s
  _ -> Nothing

-- | 'Hole' represents a player's hole cards in a game of Texas Hold\'Em
data Hole
  = -- | First 'Card' is expected to be '>' the second
    UnsafeHole !Card !Card
  deriving (Eq, Ord, Show, Read, Generic)

-- | Unsafely create a new 'Hole'. The first 'Card' should be '>' than the second.
-- See 'mkHole' for a safe way to create a 'Hole'.
unsafeHole :: Card -> Card -> Hole
unsafeHole = UnsafeHole

-- | >>> "AcKd" :: Hole
-- UnsafeHole (Card {rank = Ace, suit = Club}) (Card {rank = King, suit = Diamond})
instance IsString Hole where
  fromString str =
    fromMaybe invalidHole . holeFromShortTxt $ T.pack str
    where
      invalidHole = error $ "Invalid Hole: " <> str

-- | >>> pretty <$> mkHole (Card Ace Heart) (Card King Spade)
-- Just AhKs
instance Pretty Hole where
  pretty (UnsafeHole c1 c2) = pretty c1 <> pretty c2

-- | The 'Arbitrary' instance for 'Hole' generates values whose 'Card' members
-- are already normalised.
instance Arbitrary Hole where
  arbitrary = elements allHoles

-- | >>> holeToShortTxt "AcKd"
-- "AcKd"
holeToShortTxt :: Hole -> Text
holeToShortTxt (UnsafeHole c1 c2) = cardToShortTxt c1 <> cardToShortTxt c2

-- | >>> holeFromShortTxt "AcKd"
-- Just (UnsafeHole (Card {rank = Ace, suit = Club}) (Card {rank = King, suit = Diamond}))
-- >>> ("KdAc" :: Hole) == "AcKd"
-- True
--
-- prop> \h -> holeFromShortTxt (holeToShortTxt h) == Just h
holeFromShortTxt :: Text -> Maybe Hole
holeFromShortTxt (T.splitAt 2 -> (c1, T.splitAt 2 -> (c2, T.unpack -> []))) =
  join $ mkHole <$> cardFromShortTxt c1 <*> cardFromShortTxt c2
holeFromShortTxt _ = Nothing

-- | Returns a 'Hole' if the incoming 'Card's are unique, else 'Nothing'.
-- Note that 'mkHole' automatically normalises the order of the given 'Card's. See 'Hole' for details.
--
-- prop> \c1 c2 -> mkHole c1 c2 == mkHole c2 c1
--
-- prop> \c1 c2 -> (c1 /= c2) ==> isJust (mkHole c1 c2)
mkHole :: Card -> Card -> Maybe Hole
mkHole c1 c2 =
  if c1 /= c2
    then Just $ if c1 > c2 then UnsafeHole c1 c2 else UnsafeHole c2 c1
    else Nothing

-- | All possible valid 'Hole's (the 'Hole's are already normalised).
--
-- >>> length allCards * length allCards
-- 2704
-- >>> length allHoles
-- 1326
-- >>> Data.List.nub allHoles == allHoles
-- True
-- >>> pretty $ take 10 allHoles
-- [AsAh, AsAd, AhAd, AsAc, AhAc, AdAc, AsKs, AhKs, AdKs, AcKs]
allHoles :: [Hole]
allHoles = reverse $ do
  r1 <- allRanks
  r2 <- enumFrom r1
  (s1, s2) <-
    if r1 == r2
      then [(s1, s2) | s1 <- allSuits, s2 <- drop 1 (enumFrom s1)]
      else liftA2 (,) allSuits allSuits
  pure $ unsafeHole (Card r2 s2) (Card r1 s1)

-- | A 'ShapedHole' is the 'Suit'-normalised representation of a
-- poker 'Hole'. For example, the 'Hole' "King of Diamonds, 5 of Hearts" is often referred
-- to as "King-5 offsuit".
--
-- To construct a 'ShapedHole', see 'mkPair', 'mkOffsuit', and mkSuited'.
--
-- >>> "22p" :: ShapedHole
-- Pair Two
-- >>> "A4o" :: ShapedHole
-- UnsafeOffsuit Ace Four
-- >>> "KJs" :: ShapedHole
-- UnsafeSuited King Jack
data ShapedHole
  = Pair !Rank
  | -- | First 'Rank' should be '>' the second
    UnsafeOffsuit !Rank !Rank
  | -- | First 'Rank' should be '>' the second
    UnsafeSuited !Rank !Rank
  deriving (Eq, Ord, Show, Read, Generic)

-- | First 'Rank' should '>' than second 'Rank'
unsafeOffsuit :: Rank -> Rank -> ShapedHole
unsafeOffsuit = UnsafeOffsuit

-- | First 'Rank' should be '>' than second 'Rank'
unsafeSuited :: Rank -> Rank -> ShapedHole
unsafeSuited = UnsafeSuited

-- | >>> "AKs" :: ShapedHole
-- UnsafeSuited Ace King
-- >>> "AKo" :: ShapedHole
-- UnsafeOffsuit Ace King
-- >>> "AAp" :: ShapedHole
-- Pair Ace
-- >>> "KAs" == ("AKs" :: ShapedHole)
-- True
instance IsString ShapedHole where
  fromString str = case str of
    [r1, r2, s] ->
      fromMaybe invalidShapedHole $ do
        r1' <- chrToRank r1
        r2' <- chrToRank r2
        case s of
          'p' -> if r1' == r2' then Just $ mkPair r1' else Nothing
          'o' -> mkOffsuit r1' r2'
          's' -> mkSuited r1' r2'
          _ -> Nothing
    _ -> invalidShapedHole
    where
      invalidShapedHole = error $ "Invalid ShapedHole: " <> str

-- | >>> pretty $ take 10 allShapedHoles
-- [AAp, AKs, AQs, AJs, ATs, A9s, A8s, A7s, A6s, A5s]
instance Pretty ShapedHole where
  pretty = pretty . shapedHoleToShortTxt

-- | The 'Arbitrary' instance for 'ShapedHole' generates values whose 'Rank' members
-- are already normalised.
instance Arbitrary ShapedHole where
  arbitrary = elements allShapedHoles

-- | >>> shapedHoleToShortTxt (mkPair Ace)
-- "AAp"
-- >>> shapedHoleToShortTxt <$> (mkOffsuit Ace King)
-- Just "AKo"
-- >>> shapedHoleToShortTxt <$> (mkSuited Ace King)
-- Just "AKs"
shapedHoleToShortTxt :: ShapedHole -> Text
shapedHoleToShortTxt (UnsafeOffsuit r1 r2) = rankToChr r1 `T.cons` rankToChr r2 `T.cons` "o"
shapedHoleToShortTxt (UnsafeSuited r1 r2) = rankToChr r1 `T.cons` rankToChr r2 `T.cons` "s"
shapedHoleToShortTxt (Pair r) = rankToChr r `T.cons` rankToChr r `T.cons` "p"

-- | Build a pair 'ShapedHole' from the given 'Rank'
mkPair :: Rank -> ShapedHole
mkPair = Pair

-- | Returns a suited 'ShapedHole' if the incoming 'Rank's are unique, else 'Nothing'.
-- Note that 'mkSuited' normalises the order of the incoming 'Rank's.
--
-- prop> \r1 r2 -> mkSuited r1 r2 == mkSuited r2 r1
mkSuited :: Rank -> Rank -> Maybe ShapedHole
mkSuited r1 r2 =
  if r1 /= r2
    then Just $ if r1 > r2 then UnsafeSuited r1 r2 else UnsafeSuited r2 r1
    else Nothing

-- | Returns an offsuit 'ShapedHole' if the incoming 'Rank's are unique, else 'Nothing'.
-- Note that the internal representation of 'ShapedHole' is normalised:
--
-- prop> \r1 r2 -> mkOffsuit r1 r2 == mkOffsuit r2 r1
mkOffsuit :: Rank -> Rank -> Maybe ShapedHole
mkOffsuit r1 r2 =
  if r1 /= r2
    then Just $ if r1 > r2 then UnsafeOffsuit r1 r2 else UnsafeOffsuit r2 r1
    else Nothing

-- | >>> length allShapedHoles
-- 169
-- >>> Data.List.nub allShapedHoles == allShapedHoles
-- True
-- >>> pretty $ take 15 allShapedHoles
-- [AAp, AKs, AQs, AJs, ATs, A9s, A8s, A7s, A6s, A5s, A4s, A3s, A2s, AKo, KKp]
allShapedHoles :: [ShapedHole]
allShapedHoles = reverse $ do
  rank1 <- allRanks
  rank2 <- allRanks
  return $ case compare rank1 rank2 of
    GT -> unsafeSuited rank1 rank2
    EQ -> mkPair rank1
    LT -> unsafeOffsuit rank2 rank1

-- | >>> fmap holeToShortTxt . shapedHoleToHoles $ "55p"
-- ["5d5c","5h5c","5s5c","5h5d","5s5d","5s5h"]
-- >>> fmap holeToShortTxt . shapedHoleToHoles $ "97o"
-- ["9c7d","9c7h","9c7s","9d7c","9d7h","9d7s","9h7c","9h7d","9h7s","9s7c","9s7d","9s7h"]
-- >>> fmap holeToShortTxt . shapedHoleToHoles $ "QTs"
-- ["QcTc","QdTd","QhTh","QsTs"]
shapedHoleToHoles :: ShapedHole -> [Hole]
shapedHoleToHoles = \case
  Pair r -> do
    s1 <- allSuits
    s2 <- drop (fromEnum s1 + 1) allSuits
    pure . fromJust $ mkHole (Card r s1) (Card r s2)
  UnsafeOffsuit r1 r2 -> do
    s1 <- allSuits
    s2 <- filter (s1 /=) allSuits
    pure . fromJust $ mkHole (Card r1 s1) (Card r2 s2)
  UnsafeSuited r1 r2 -> do
    s <- allSuits
    pure . fromJust $ mkHole (Card r1 s) (Card r2 s)

-- | >>> holeToShapedHole "AcKd"
-- UnsafeOffsuit Ace King
-- >>> holeToShapedHole "AcKc"
-- UnsafeSuited Ace King
-- >>> holeToShapedHole "AcAs"
-- Pair Ace
holeToShapedHole :: Hole -> ShapedHole
holeToShapedHole (UnsafeHole (Card r1 s1) (Card r2 s2))
  | r1 == r2 = mkPair r1
  | s1 == s2 = unsafeSuited r1 r2
  | otherwise = unsafeOffsuit r1 r2

-- | A 'Deck' of 'Card's
newtype Deck = UnsafeDeck [Card] deriving (Read, Show, Eq)

-- | A unshuffled 'Deck' with all 'Card's
--
-- >>> freshDeck == unsafeDeck allCards
-- True
freshDeck :: Deck
freshDeck = UnsafeDeck allCards

-- | The input 'Card's are not checked in any way.
unsafeDeck :: [Card] -> Deck
unsafeDeck = UnsafeDeck