-- Copyright (c) 2014-2015 Jonathan M. Lange -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. {-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE ViewPatterns #-} module Haverer.Round ( -- A Round of a game of Love Letter. Round , makeRound , playTurn , playTurn' -- The results of playTurn , BadAction , Result(..) , Event(..) -- Information about a Round , currentPlayer , currentTurn , getActivePlayers , getPlayer , getPlayerMap , getPlayers , getWinners , nextPlayer , remainingCards -- The outcome of a Round , Victory(..) , victory -- Properties used for testing that rely on unexposed fields. , prop_allCardsPresent , prop_burnCardsSame , prop_multipleActivePlayers , prop_ringIsActivePlayers ) where import BasicPrelude hiding (round) import Control.Error import Control.Monad.Except import Control.Monad.State import Control.Lens hiding (chosen) import Data.Maybe (fromJust) import qualified Data.Map as Map import Haverer.Action ( BadPlay, bustingHand, Play(..), Action, getTarget, playToAction, viewAction) import Haverer.Deck (Card(..), Complete, Deck, deal, Incomplete, pop) import qualified Haverer.Deck as Deck import Haverer.Player ( bust, discardAndDraw, eliminate, getDiscards, getHand, isProtected, makePlayer, playCard, Player, PlayerSet, protect, swapHands, toPlayers, unprotect ) import Haverer.Internal.Error (assertRight) import Haverer.Internal.Ring (Ring, advance1, currentItem, dropItem1, makeRing, nextItem) import qualified Haverer.Internal.Ring as Ring -- XXX: Consider popping this out so that it's the constructor of the Round. data RoundState = NotStarted | Turn Card | Playing | Over deriving Show data Round playerId = Round { _stack :: Deck Incomplete, _playOrder :: Ring playerId, _players :: Map playerId Player, _roundState :: RoundState, _burn :: Card } deriving Show makeLenses ''Round -- | Make a new round, given a complete Deck and a set of players. makeRound :: (Ord playerId, Show playerId) => Deck Complete -> PlayerSet playerId -> Round playerId makeRound deck playerSet = nextTurn $ case deal deck (length playerList) of (Just cards, remainder) -> case pop remainder of (Nothing, _) -> terror ("Not enough cards for burn: " ++ show deck) (Just burn', stack') -> Round { _stack = stack', _playOrder = fromJust (makeRing playerList), _players = Map.fromList $ zip playerList (map makePlayer cards), _roundState = NotStarted, _burn = burn' } _ -> terror ("Given a complete deck - " ++ show deck ++ "- that didn't have enough cards for players - " ++ show playerSet) where playerList = toPlayers playerSet -- | The number of cards remaining in the deck. remainingCards :: Round playerId -> Int remainingCards = length . Deck.toList . view stack -- | The IDs of all of the players. getPlayers :: Round playerId -> [playerId] getPlayers = Map.keys . view players -- | The IDs of all of the active players. getActivePlayers :: Round playerId -> [playerId] getActivePlayers = Ring.toList . view playOrder -- TODO: Rather than exporting Player, export functions that will return -- active state, protected state and discard pile. Then move Player data type -- and functions to somewhere hidden (Internal maybe?), because it really is -- just as implementation detail of Round. -- | A map of player IDs to players. getPlayerMap :: Round playerId -> Map playerId Player getPlayerMap = view players -- | Get the player with the given ID. Nothing if there is no such player. getPlayer :: Ord playerId => Round playerId -> playerId -> Maybe Player getPlayer round pid = view (players . at pid) round -- | Draw a card from the top of the Deck. Returns the card and a new Round. drawCard :: Monad m => StateT (Round playerId) m (Maybe Card) drawCard = do (card, stack') <- pop <$> use stack assign stack stack' return card -- | Progress the Round to the next turn. nextTurn :: (Show playerId, Ord playerId) => Round playerId -> Round playerId nextTurn round = flip execState round $ do current <- use roundState case current of Over -> return () Turn _ -> terror "Cannot advance to next turn while waiting for play." NotStarted -> do card <- drawCard assign roundState $ case card of Just card' -> Turn card' Nothing -> Over -- XXX: Not actually possible. Playing -> do -- To advance to the next turn we need to make sure that there are cards -- in the deck and that there is more than one player. card <- drawCard newPlayOrder <- advance1 <$> use playOrder case (card, newPlayOrder) of (Nothing, _) -> end round (_, Left _) -> end round (Just card', Right newPlayOrder') -> do modify $ unprotectPlayer (currentItem newPlayOrder') assign roundState (Turn card') assign playOrder newPlayOrder' where end :: Round playerId -> State (Round playerId) () end rnd = do put rnd assign roundState Over unprotectPlayer pid rnd = assertRight "Couldn't unprotect current player: " (modifyActivePlayer rnd pid unprotect) -- | The ID of the current player. If the Round is over or not started, this -- will be Nothing. currentPlayer :: Round playerId -> Maybe playerId currentPlayer rnd = case view roundState rnd of Over -> Nothing NotStarted -> Nothing Turn _ -> Just $ (currentItem . view playOrder) rnd Playing -> Just $ (currentItem . view playOrder) rnd currentTurn :: Ord playerId => Round playerId -> Maybe (playerId, (Card, Card)) currentTurn rnd = do pid <- currentPlayer rnd hand <- getHand =<< getPlayer rnd pid d <- dealt return (pid, (d, hand)) where dealt = case view roundState rnd of Turn d -> Just d _ -> Nothing nextPlayer :: Round playerId -> Maybe playerId nextPlayer rnd = case view roundState rnd of Over -> Nothing NotStarted -> Just $ currentItem playOrder' Turn _ -> Just $ nextItem playOrder' Playing -> Just $ nextItem playOrder' where playOrder' = view playOrder rnd data BadAction playerId = NoSuchPlayer playerId | InactivePlayer playerId | InvalidPlay (BadPlay playerId) | WrongCard Card (Card, Card) | PlayWhenBusted | NoPlaySpecified | RoundOver deriving Show -- | A change to the Round that comes as result of a player's actions. data Event playerId = -- | Nothing happened. What the player did had no effect. NoChange | -- | The player is now protected. Protected playerId | -- | The first player has been forced to swap hands with the second. SwappedHands playerId playerId | -- | The player has been eliminated from the round. Eliminated playerId | -- | The player has been forced to discard their hand. ForcedDiscard playerId | -- | The second player has been forced to show their hand to the first. ForcedReveal playerId playerId Card deriving (Eq, Show) -- | The result of a turn. data Result playerId = -- | The player whose turn it was "busted out", they held the Minister and -- another high card, and thus didn't get to play. BustedOut playerId Card Card | -- | The player performed an Action resulting in Event. Played (Action playerId) (Event playerId) deriving (Eq, Show) type ActionM id a = Either (BadAction id) a -- | Translate a player action into a change to make to the round. -- Will return errors if the action is for or against an inactive or -- nonexistent player. -- -- If the target player is protected, will return the identity result, -- NoChange. actionToEvent :: (Ord playerId, Show playerId) => Round playerId -> Action playerId -> ActionM playerId (Event playerId) actionToEvent round action@(viewAction -> (pid, _, play)) = do (_, sourceHand) <- getActivePlayerHand round pid case getTarget play of Nothing -> return $ noTarget action Just target -> do (targetPlayer, targetHand) <- getActivePlayerHand round target if fromJust (isProtected targetPlayer) then return NoChange else return $ withTarget sourceHand targetHand action where noTarget (viewAction -> (_, Priestess, NoEffect)) = Protected pid noTarget (viewAction -> (_, Minister, NoEffect)) = NoChange noTarget (viewAction -> (_, Prince, NoEffect)) = Eliminated pid noTarget _ = terror $ "We thought " ++ show action ++ " had no target." withTarget _ targetCard (viewAction -> (_, Soldier, Guess target guess)) | targetCard == guess = Eliminated target | otherwise = NoChange withTarget _ targetCard (viewAction -> (_, Clown, Attack target)) = ForcedReveal pid target targetCard withTarget sourceHand targetHand (viewAction -> (_, Knight, Attack target)) = case compare sourceHand targetHand of LT -> Eliminated pid EQ -> NoChange GT -> Eliminated target withTarget _ Prince (viewAction -> (_, Wizard, Attack target)) = Eliminated target withTarget _ _ (viewAction -> (_, Wizard, Attack target)) = ForcedDiscard target withTarget _ _ (viewAction -> (_, General, Attack target)) = SwappedHands target pid withTarget _ _ _ = terror $ "Invalid action: " ++ show action -- XXX: Lots of these re-get players from the Round that have already been -- retrieved by actionToEvent. Perhaps we could include that data in the Event -- structure so this simply returns a Round. -- | Apply a change to the Round. applyEvent :: Ord playerId => Round playerId -> Event playerId -> ActionM playerId (Round playerId) applyEvent round NoChange = return round applyEvent round (Protected pid) = modifyActivePlayer round pid protect applyEvent round (SwappedHands pid1 pid2) = do p1 <- getActivePlayer round pid1 p2 <- getActivePlayer round pid2 let (p1', p2') = swapHands p1 p2 in return $ (replace pid2 p2' . replace pid1 p1') round where replace pid p rnd = setActivePlayer rnd pid p applyEvent round (Eliminated pid) = modifyActivePlayer round pid eliminate applyEvent round (ForcedDiscard pid) = let (card, round') = runState drawCard round in modifyActivePlayer round' pid (`discardAndDraw` card) applyEvent round (ForcedReveal {}) = return round -- | Play a turn in a Round. -- -- This is the main function in this module. -- -- A turn has two steps. First, the player draws a card. If their hand "busts -- out" (due to holding the Minister and another high card), then they are -- eliminated and play proceeds to the next player. This is the `Left` return -- value, which returns the new Round and a Result indicating the player bust -- out. -- -- Second, the player plays one of these two cards. This is the `Right` return -- value, a function that takes the players chosen card and play, and returns -- either a BadAction or a new Round together with the Result of the play. playTurn :: (Ord playerId, Show playerId) => Round playerId -> Either (ActionM playerId (Result playerId, Round playerId)) (Card -> Play playerId -> ActionM playerId (Result playerId, Round playerId)) playTurn round = do (playerId, (dealt, hand)) <- note (Left RoundOver) (currentTurn round) let player = assertRight "Current player is not active: " (getActivePlayer round playerId) if bustingHand dealt hand then Left $ return $ bustOut playerId dealt hand else Right $ handlePlay playerId player dealt hand where handlePlay playerId player dealt hand chosen play = do player' <- note (WrongCard chosen (dealt, hand)) (playCard player dealt chosen) let round' = setActivePlayer (set roundState Playing round) playerId player' action <- fmapL InvalidPlay (playToAction playerId chosen play) result <- actionToEvent round' action round'' <- applyEvent round' result return (Played action result, nextTurn round'') bustOut pid dealt hand = let bustedRound = assertRight "Could not bust out player: " (modifyActivePlayer round pid (`bust` dealt)) in (BustedOut pid dealt hand, nextTurn (set roundState Playing bustedRound)) -- | Play a turn in a Round -- -- Similar to playTurn, except that instead of splitting the turn into two -- phases, there is a single, optional play. If the hand is a busting hand, -- then the play must be Nothing; if not, the play must be specified. playTurn' :: (Ord playerId, Show playerId) => Round playerId -> Maybe (Card, Play playerId) -> ActionM playerId (Result playerId, Round playerId) playTurn' round optionalPlay = do result <- case playTurn round of Left action -> action Right handler -> do (card, play) <- note NoPlaySpecified optionalPlay handler card play case (optionalPlay, result) of (Just _, (BustedOut {}, _)) -> throwError PlayWhenBusted _ -> return result data Victory playerId -- | The given player is the only survivor. = SoleSurvivor playerId Card -- | These players have the highest card. | HighestCard Card [playerId] [(playerId, Card)] deriving (Eq, Show) -- | The currently surviving players in the round, with their cards. survivors :: Round playerId -> [(playerId, Card)] survivors = Map.toList . Map.mapMaybe getHand . view players -- | If the Round is Over, return the Victory data. Otherwise, Nothing. victory :: Round playerId -> Maybe (Victory playerId) victory (round@Round { _roundState = Over }) = case survivors round of [(pid, card)] -> Just $ SoleSurvivor pid card xs -> let (best:rest) = reverse (groupBy ((==) `on` snd) (sortBy (compare `on` snd) xs)) in Just $ HighestCard (snd $ head best) (map fst best) (concat rest) victory _ = Nothing getWinners :: Victory playerId -> [playerId] getWinners (SoleSurvivor pid _) = [pid] getWinners (HighestCard _ pids _) = pids -- | Update the given player in Round. If the update function returns Nothing, -- then that is taken to mean the player was inactive. modifyActivePlayer :: Ord playerId => Round playerId -> playerId -> (Player -> Player) -> ActionM playerId (Round playerId) modifyActivePlayer rnd pid f = setActivePlayer rnd pid <$> f <$> getActivePlayer rnd pid -- | Replace the given player in the Round. If the new player is inactive, -- then the player is dropped from the cycle of play. setActivePlayer :: Ord playerId => Round playerId -> playerId -> Player -> Round playerId setActivePlayer round pid newP = case getHand newP of Nothing -> dropPlayer pid Just _ -> round' where round' = over players (set (at pid) (Just newP)) round dropPlayer p = case dropItem1 (view playOrder round') p of Left _ -> set roundState Over round' Right newOrder -> set playOrder newOrder round' -- | Get the given player, asserting they must be active. Will return a Left -- if no player is found or if the requested player is inactive. getActivePlayer :: Ord playerId => Round playerId -> playerId -> ActionM playerId Player getActivePlayer round pid = fst <$> getActivePlayerHand round pid getActivePlayerHand :: Ord playerId => Round playerId -> playerId -> ActionM playerId (Player, Card) getActivePlayerHand round pid = do player <- note (NoSuchPlayer pid) (getPlayer round pid) hand <- note (InactivePlayer pid) (getHand player) return (player, hand) -- | Are all the cards in the Round? prop_allCardsPresent :: Round playerId -> Bool prop_allCardsPresent = isJust . Deck.makeDeck . allCards where allCards rnd = _burn rnd : ( (Deck.toList . view stack) rnd ++ (concatMap getDiscards . Map.elems . view players) rnd ++ (mapMaybe getHand . Map.elems . view players) rnd) ++ ( case _roundState rnd of Turn x -> [x] _ -> []) prop_burnCardsSame :: [Round playerId] -> Bool prop_burnCardsSame (x:xs) = all ((== view burn x) . view burn) xs prop_burnCardsSame [] = True prop_ringIsActivePlayers :: Eq playerId => Round playerId -> Bool prop_ringIsActivePlayers r = (Map.keys . Map.mapMaybe getHand . _players) r == (Ring.toList . view playOrder) r prop_multipleActivePlayers :: Round playerId -> Bool prop_multipleActivePlayers r = case view roundState r of Over -> True _ -> Ring.ringSize (view playOrder r) > 1