haverer-0.1.0.0: lib/Haverer/Round.hs
-- Copyright (c) 2014-2015 Jonathan M. Lange <jml@mumak.net>
--
-- 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