packages feed

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

module Wordify.Rules.FormedWord
  ( FormedWords,
    FormedWord,
    PlacedSquares,
    allWords,
    mainWord,
    adjacentWords,
    playerPlaced,
    playerPlacedMap,
    scoreWord,
    overallScore,
    bingoBonusApplied,
    prettyPrintIntersections,
    makeString,
    wordStrings,
    wordsWithScores,
    wordsFormedMidGame,
    wordFormedFirstMove,
  )
where

import Control.Applicative
import Control.Error (note)
import Control.Monad
import Data.Char
import Data.Foldable as Foldable
import Data.Functor
import qualified Data.List.Split as S
import Data.Map as Map
import Data.Maybe (isJust)
import qualified Data.Maybe as M
import Data.Sequence as Seq
import Wordify.Rules.Board
import Wordify.Rules.LetterBag
import Wordify.Rules.Pos
import Wordify.Rules.ScrabbleError
import Wordify.Rules.Square
import Wordify.Rules.Tile

data FormedWords
  = FirstWord FormedWord
  | FormedWords
      { main :: FormedWord,
        otherWords :: [FormedWord],
        placed :: PlacedSquares
      }
  deriving (Show, Eq)

type FormedWord = Seq (Pos, Square)

type PlacedSquares = Map Pos Square

-- |
--    Pretty prints the places a given formed word intersects with letters that were already on the board
--    using brackets. E.g. T(HI)S would denote that the player placed a 'T' and an 'S' on to the board, using
--    the already placed word 'HI' to form the new word 'THIS'.
prettyPrintIntersections :: PlacedSquares -> FormedWord -> String
prettyPrintIntersections placed formedWord = denotePassThroughs placed $ Foldable.toList formedWord
  where
    denotePassThroughs :: PlacedSquares -> [(Pos, Square)] -> String
    denotePassThroughs placed formed =
      let breaks = brokenSquaresToChars $ S.split (splitter placed) formed
       in Prelude.concat (mapEverySecond wrapInBrackets breaks)

    alreadyPlacedOpening :: String
    alreadyPlacedOpening = "("

    alreadyPlacedClosing :: String
    alreadyPlacedClosing = ")"

    wrapInBrackets :: String -> String
    wrapInBrackets str = concat [alreadyPlacedOpening, str, alreadyPlacedClosing]

    mapEverySecond :: (a -> a) -> [a] -> [a]
    mapEverySecond f = Prelude.zipWith ($) (cycle [id, f])

    squareToString :: Square -> String
    squareToString sq = M.fromMaybe "_" $ tileIfOccupied sq >>= printString

    -- Splits whenever we encounter a series of squares that the player's word passes through
    -- on the board
    splitter :: PlacedSquares -> S.Splitter (Pos, Square)
    splitter placed = S.condense $ S.whenElt (flip (Map.notMember . fst) placed)

    brokenSquaresToChars :: [[(Pos, Square)]] -> [String]
    brokenSquaresToChars brokenSquares = Prelude.map (concatMap (squareToString . snd)) brokenSquares

-- |
--    Scores an individual word.
--
--    Note: overallscore should be used to obtain the overall score as it takes into account any bingo bonuses.
scoreWord :: PlacedSquares -> FormedWord -> Int
scoreWord played formed =
  let (notAlreadyPlaced, onBoardAlready) = partitionPlaced played formed
   in scoreSquares onBoardAlready notAlreadyPlaced
  where
    partitionPlaced placed formed = (mapTuple . fmap) snd $ Seq.partition (\(pos, _) -> Map.member pos placed) formed

    mapTuple :: (a -> b) -> (a, a) -> (b, b)
    mapTuple f (a1, a2) = (f a1, f a2)

-- |
--    Calculates the overall score of the play.
--
--    If a player managed to place all 7 of their letters, then they receive a bingo bonus of 50 points.
overallScore :: FormedWords -> Int
overallScore formedWords =
  let wordsScore = Prelude.sum $ Prelude.map (scoreWord placed) $ allWords formedWords
   in case (Prelude.length $ keys $ placed) of
        7 -> wordsScore + 50
        _ -> wordsScore
  where
    placed = playerPlacedMap formedWords

-- |
--    All the words formed by a play.
allWords :: FormedWords -> [FormedWord]
allWords (FormedWords main adjacentWords _) = main : adjacentWords
allWords (FirstWord firstWord) = [firstWord]

-- |
--     Returns the word formed by the first move on the board. The word must cover
--     the star tile, and be linear. Any blank tiles must be labeled.
wordFormedFirstMove :: Board -> Map Pos Tile -> Either ScrabbleError FormedWords
wordFormedFirstMove board tiles
  | starPos `Map.notMember` tiles = Left DoesNotCoverTheStarTile
  | otherwise = placedSquares board tiles >>= fmap (FirstWord . main) . wordsFormed board

-- |
--    Returns the words formed by the tiles played on the board. A played word
--    must be connected to a tile already on the board (or intersect tiles on the board),
--    and be formed linearly. Any blank tiles must be labeled.
wordsFormedMidGame :: Board -> Map Pos Tile -> Either ScrabbleError FormedWords
wordsFormedMidGame board tiles =
  placedSquares board tiles
    >>= \squares ->
      wordsFormed board squares >>= \formed ->
        let FormedWords x xs _ = formed
         in -- Check it connects to at least one other word on the board
            if Seq.length x > Map.size squares || not (Prelude.null xs)
              then Right $ FormedWords x xs squares
              else Left DoesNotConnectWithWord

-- |
--    Returns the main word formed by the played tiles. The main word is
--    the linear stretch of tiles formed by the tiles placed.
mainWord :: FormedWords -> FormedWord
mainWord (FirstWord word) = word
mainWord formed = main formed

-- |
--    Returns the list of words which were adjacent to the main word formed.
adjacentWords :: FormedWords -> [FormedWord]
adjacentWords (FirstWord _) = []
adjacentWords formed = otherWords formed

-- |
--    Returns the list of positions mapped to the squares that the player placed their tiles on.
playerPlaced :: FormedWords -> [(Pos, Square)]
playerPlaced (FirstWord word) = Foldable.toList word
playerPlaced formed = Map.toList $ placed formed

playerPlacedMap :: FormedWords -> Map Pos Square
playerPlacedMap (FirstWord word) = Map.fromList $ Foldable.toList word
playerPlacedMap formed = placed formed

-- |
--    Scores the words formed by the tiles placed. The first item in the tuple is the overall
--    score, while the second item is the list of scores for all the words formed.
wordsWithScores :: FormedWords -> (Int, [(String, Int)])
wordsWithScores formedWords = (overallScore formedWords, fmap wordAndScore allFormedWords)
  where
    allFormedWords = allWords formedWords
    wordAndScore formedWord = (makeString formedWord, scoreWord (playerPlacedMap formedWords) formedWord)

-- |
--    Returns true if the player placed all 7 of their letters while forming these words, incurring a + 50 score bonus.
bingoBonusApplied :: FormedWords -> Bool
bingoBonusApplied formed = Prelude.length (playerPlaced formed) == 7

-- |
--    Returns the words formed by the play as strings.
wordStrings :: FormedWords -> [String]
wordStrings (FirstWord word) = [makeString word]
wordStrings formed = Prelude.map makeString $ main formed : otherWords formed

makeString :: FormedWord -> String
makeString word = concat <$> M.mapMaybe (\(_, sq) -> tileIfOccupied sq >>= tileString) $ Foldable.toList word

{-
  Checks that the tiles can be placed, and if so returns a map of the squares at the placed positions.
  A tile may be placed if the square is not already occupied, and if it is not an unlabeled blank tile.
-}
placedSquares :: Board -> Map Pos Tile -> Either ScrabbleError (Map Pos Square)
placedSquares board tiles = squares
  where
    squares =
      Map.fromList
        <$> sequence
          ( ( \(pos, tile) -> squareIfUnoccupied (pos, tile)
            )
              <$> mapAsList
          )

    squareIfUnoccupied (pos, tile) =
      maybe
        (Left (PlacedTileOnOccupiedSquare pos tile))
        ( \sq ->
            Right (pos, putTileOn sq tile)
        )
        $ unoccupiedSquareAt board pos
    mapAsList = Map.toList tiles

wordsFormed :: Board -> Map Pos Square -> Either ScrabbleError FormedWords
wordsFormed board tiles
  | Map.null tiles = Left NoTilesPlaced
  | otherwise =
    formedWords >>= \formed ->
      case formed of
        x : xs -> Right $ FormedWords x xs tiles
        [] -> Left NoTilesPlaced
  where
    formedWords =
      maybe
        (Left $ MisplacedLetter maxPos)
        ( \direction ->
            middleFirstWord direction
              >>= ( \middle ->
                      let (midWord, _) = middle
                       in let mainLine = preceding direction minPos >< midWord >< after direction maxPos
                           in Right $ mainLine : adjacentToMain (swapDirection direction)
                  )
        )
        getDirection

    preceding direction pos = case direction of
      Horizontal -> lettersLeft board pos
      Vertical -> lettersBelow board pos
    after direction pos = case direction of
      Horizontal -> lettersRight board pos
      Vertical -> lettersAbove board pos

    (minPos, _) = Map.findMin tiles
    (maxPos, _) = Map.findMax tiles

    adjacentToMain direction =
      Prelude.filter (\word -> Seq.length word > 1) $
        Prelude.map
          ( \(pos, square) ->
              (preceding direction pos |> (pos, square)) >< after direction pos
          )
          placedList

    middleFirstWord direction =
      case placedList of
        [x] -> Right (Seq.singleton x, minPos)
        (x : xs) ->
          foldM
            ( \(word, lastPos) (pos, square) ->
                if not $ stillOnPath lastPos pos direction
                  then Left $ MisplacedLetter pos
                  else
                    if isDirectlyAfter lastPos pos direction
                      then Right (word |> (pos, square), pos)
                      else
                        let between = after direction lastPos
                         in if expectedLettersInbetween direction lastPos pos between
                              then Right (word >< (between |> (pos, square)), pos)
                              else Left $ MisplacedLetter pos
            )
            (Seq.singleton x, minPos)
            xs
        [] -> Left NoTilesPlaced

    placedList = Map.toAscList tiles

    stillOnPath lastPos thisPos direction = staticDirectionGetter direction thisPos == staticDirectionGetter direction lastPos
    expectedLettersInbetween direction lastPos currentPos between =
      Seq.length between + 1 == movingDirectionGetter direction currentPos - movingDirectionGetter direction lastPos

    swapDirection direction = if direction == Horizontal then Vertical else Horizontal

    getDirection
      -- If only one tile is placed, we look for the first tile it connects with if any. If it connects with none, we return 'Nothing'
      | (minPos == maxPos) && (not (Seq.null (lettersLeft board minPos)) || not (Seq.null (lettersRight board minPos))) = Just Horizontal
      | (minPos == maxPos) && (not (Seq.null (lettersBelow board minPos)) || not (Seq.null (lettersAbove board minPos))) = Just Vertical
      | xPos minPos == xPos maxPos = Just Vertical
      | yPos minPos == yPos maxPos = Just Horizontal
      | otherwise = Nothing

    staticDirectionGetter direction pos = if direction == Horizontal then yPos pos else xPos pos

    movingDirectionGetter direction pos = if direction == Horizontal then xPos pos else yPos pos

    isDirectlyAfter pos nextPos direction = movingDirectionGetter direction nextPos == movingDirectionGetter direction pos + 1