packages feed

wordify-0.3.0.0: src/Wordify/Rules/LetterBag.hs

module Wordify.Rules.LetterBag
  ( LetterBag,
    ValidTiles,
    validLetters,
    makeBag,
    tiles,
    bagFromTiles,
    makeBagUsingGenerator,
    takeLetters,
    exchangeLetters,
    shuffleBag,
    shuffleWithNewGenerator,
    bagSize,
    getGenerator,
  )
where

import qualified Control.Exception as Exc
import Control.Monad
import Control.Monad.ST
import Data.Array.IO
import Data.Array.ST
import Data.Char
import qualified Data.Map as M
import qualified Data.Maybe as Mb
import Data.STRef
import qualified Data.Set as S
import System.IO
import System.Random
import Text.ParserCombinators.Parsec
import Wordify.Rules.LetterBag.Internal
import Wordify.Rules.ScrabbleError
import Wordify.Rules.Tile

-- |
--  Creates a letter bag from a file where each line contains a space delimited letter character, letter value, and letter distribution.
--  A blank letter is represented by a '_' character and has a disribution, but no value.
--
-- If successful, the letter bag is shuffled before it is returned.
makeBag :: FilePath -> IO (Either ScrabbleError LetterBag)
makeBag path = do
  ioOutcome <- Exc.try $ withFile path ReadMode (hGetContents >=> parseBagString path) :: IO (Either Exc.IOException (Either ScrabbleError LetterBag))
  case ioOutcome of
    Left _ -> return $ Left (LetterBagFileNotOpenable path)
    Right x -> return $ fmap shuffleBag x

parseBagString :: String -> String -> IO (Either ScrabbleError LetterBag)
parseBagString path bagString =
  let parseResult = parseBag bagString
   in case parseResult of
        Left err -> return $ Left (MalformedLetterBagFile path (show err))
        Right parsedTiles ->
          do
            gen <- newStdGen
            return $ Right (LetterBag parsedTiles (length parsedTiles) gen (bagLetters parsedTiles))

-- |
--  Creates a letter bag from a list of tiles. The order of the tiles is retained in the resulting letter bag.
--
--  This function is effectful as it is necessary to create a stdGen for list to allow
--  it to be shuffled using this generator in the future.
bagFromTiles :: [Tile] -> IO LetterBag
bagFromTiles bagTiles =
  do
    generator <- newStdGen
    return $ LetterBag bagTiles (length bagTiles) generator (bagLetters bagTiles)

-- |
--    Helper function to construct a LetterBag. Maps the valid letters in a letter bag
--    to the tile representing that letter on the board.
bagLetters :: [Tile] -> M.Map String Tile
bagLetters tiles =
  let maybeLetters = Mb.mapMaybe pairIfTileHasLetter tiles
   in M.fromList maybeLetters
  where
    pairIfTileHasLetter :: Tile -> Maybe (String, Tile)
    pairIfTileHasLetter tile =
      case tileString tile of
        Just lettr -> Just (lettr, tile)
        _ -> Nothing

-- |
--  Takes 'n' numbers from a letter bag, yielding 'Nothing'
--  if there is not enough tiles left in the bag or a 'Just'
--  tuple where the left value is the taken tiles, and the right
--  value is the new bag.
takeLetters :: LetterBag -> Int -> Maybe ([Tile], LetterBag)
takeLetters (LetterBag bagTiles lettersLeft gen validLetters) numTake =
  if newNumLetters < 0
    then Nothing
    else Just (taken, LetterBag newLetters newNumLetters gen validLetters)
  where
    newNumLetters = lettersLeft - numTake
    (taken, newLetters) = splitAt numTake bagTiles

-- |
--  Exchanges given tiles for the same number of tiles from the bag.
--  The exchanged letters are added to the bag, the bag is then shuffled,
--  and then the same number of tiles as exchanged are drawn from the bag.
--
--  Returns 'Nothing' if there are not enough letters in the bag to exchange
--  the given tiles for. Otherwise returns 'Just' with a tuple with the tiles
--  given, and the new letterbag.
exchangeLetters :: LetterBag -> [Tile] -> (Maybe ([Tile], LetterBag))
exchangeLetters (LetterBag bagTiles lettersLeft gen validLetters) exchanged =
  if lettersLeft == 0 then Nothing else takeLetters (shuffleBag intermediateBag) numLettersGiven
  where
    numLettersGiven = length exchanged
    intermediateBag = LetterBag (exchanged ++ bagTiles) (lettersLeft + numLettersGiven) gen validLetters

-- |
--  Shuffles the contents of a letter bag. The bag is shuffled using the random generator which was created
--  while constructing the bag.
--
-- This function should not be used when creating an additional game with a new letter bag as
-- the same seed value will be shared across games (meaning tiles will come out of the bag in
-- the same order.) When constructing an additional game, use shuffleWithNewGenerator.
shuffleBag :: LetterBag -> LetterBag
shuffleBag (LetterBag _ 0 gen validLetters) = LetterBag [] 0 gen validLetters
shuffleBag (LetterBag bagTiles size gen validLetters) =
  let (newTiles, newGenerator) = shuffle bagTiles gen size
   in (LetterBag newTiles size newGenerator validLetters)
  where
    -- Taken from http://www.haskell.org/haskellwiki/Random_shuffle
    shuffle :: [a] -> StdGen -> Int -> ([a], StdGen)
    shuffle xs randomGen listLength =
      runST
        ( do
            g <- newSTRef randomGen
            let randomRST lohi = do
                  (a, s') <- liftM (randomR lohi) (readSTRef g)
                  writeSTRef g s'
                  return a
            ar <- newArr n xs
            xs' <- forM [1 .. n] $ \i -> do
              j <- randomRST (i, n)
              vi <- readArray ar i
              vj <- readArray ar j
              writeArray ar j vi
              return vj
            gen' <- readSTRef g
            return (xs', gen')
        )
      where
        n = listLength
        newArr :: Int -> [a] -> ST s (STArray s Int a)
        newArr z zs = newListArray (1, z) zs

-- |
--  Creates a letter bag using a list of tiles, and a generator which should be used when shuffling the bag.
--  This function allows a game to be stepped through from the beginning where the moves and original generator were
--  recorded, with any shuffling yielding the same bag as in the original game.
makeBagUsingGenerator :: [Tile] -> StdGen -> LetterBag
makeBagUsingGenerator bagTiles randomGenerator = LetterBag bagTiles (length bagTiles) randomGenerator (bagLetters bagTiles)

-- |
--  Get the letter bag's current generator, which will be used to shuffle the contents of the bag in the next exchange
--  or shuffle. If taken at the start of the game, with the original list of tiles in the bag in order, the game moves
--  may be replayed in order with the original results of any shuffle retained.
getGenerator :: LetterBag -> StdGen
getGenerator = generator

-- |
--  Shuffles a letter bag using a new random generator. This function should be used when spawning a new game using
--  a letter bag with all the tiles remaining so that letter bags are unique between game instances.
shuffleWithNewGenerator :: LetterBag -> IO LetterBag
shuffleWithNewGenerator letterBag = fmap (\newGen -> shuffleBag $ letterBag {generator = newGen}) newStdGen

parseBag :: String -> Either ParseError [Tile]
parseBag contents = parse bagFile "Malformed letter bag file" contents
  where
    bagFile =
      do
        bagTiles <- many bagLine
        eof
        let flattenedTiles = concat bagTiles
        return $ flattenedTiles

    bagLine = try (letterTiles) <|> blankTiles

    letterTiles =
      do
        tileCharacter <- many letter
        _ <- space
        value <- many digit
        _ <- space
        distribution <- many digit
        _ <- newline
        return $ replicate (read distribution) (Letter (map toUpper tileCharacter) (read value))

    blankTiles =
      do
        _ <- char '_'
        _ <- space
        distribution <- many digit
        _ <- newline
        return $ replicate (read distribution) (Blank Nothing)