packages feed

battleships-1.0.0: Logic/Game.hs

----------------------------------------------------------------------------
-- |
-- Module      :  Logic.Game
-- Stability   :  experimental
-- Portability :  semi-portable
--
-- Logic and data structures of the battleships implementation.
--
-- The AI is implemented in another module to cleanly seperate it from
-- the game logic.

{-# LANGUAGE ViewPatterns #-}
module Logic.Game
  ( 
  -- * Game Functions
    boardSize
  , gridRange
  , fleetShips
  , newGame
  , humanPlayerState
  , aiPlayerState
  -- * Turn Functions
  , isDrawn
  , isTimedOut
  , numRemainingShips
  , remainingTurns
  , isCountdownStart
  , aiTurn
  , desiredMove
  , executeMove
  , executeShot
  , fireAt
  , switchRoles
  , humanTurnFire
  , moveHuman
  -- * Ship Functions
  , allSunk 
  , damageShip
  , generateFleet
  , isDamaged
  , isMovable
  , isShipSunk
  , shipsAt
  , shipSinkTime
  , sinkTime
  , moveShip
  , uncheckedMoveShip
  , numberShipsOfSize
  , shipAdmissible
  , shipCellIndex
  , shipCoordinates
  , sizesOfShips
  , unsunkShips
  ) where

import           Prelude hiding (and, or, foldl, foldr, mapM_)
import           Control.Monad hiding (forM_, mapM_)
import           Control.Monad.Random
import           Control.Monad.Trans.State (runStateT)
import           Control.Monad.State.Class (MonadState, gets, modify)
import           Data.Array
import           Data.Foldable
import           Data.List as L hiding (and, or, foldl, foldr, find)
import qualified Data.Map as Map
import           Data.Maybe
import           Data.Monoid
import           Logic.Types
import           Logic.Util

-------------------------------------------------------------------------------
-- * Constants
-------------------------------------------------------------------------------

boardSize :: (Int, Int)
boardSize = (10, 10)

-- | A sorted list of available ships
fleetShips :: [Int]
fleetShips = sort [ 2, 2, 2, 2, 3, 3, 3, 4, 4, 5 ]

-------------------------------------------------------------------------------
-- * New Games
-------------------------------------------------------------------------------

-- | Creates a new game.
newGame
  :: (AI a, MonadRandom m)
  => Rules          -- ^ rules of the game
  -> Bool           -- ^ novice mode?
  -> Bool           -- ^ developer mode?
  -> FleetPlacement -- ^ fleet of the human player
  -> Player         -- ^ beginning player
  -> m (GameState a)
newGame r noviceMode devMode pFleet begin = do 
  (ai, eFleet) <- aiInit r
  let
    humanPlayer = PlayerState [] (generateFleet pFleet) HumanPlayer []
    aiPlayer    = PlayerState [] (generateFleet eFleet) AIPlayer []
    template    = GameState
      { currentPlayer  = undefined
      , otherPlayer    = undefined
      , aiState        = ai
      , gameRules      = r
      , noviceModeOpt  = noviceMode
      , devModeOpt     = devMode
      , expectedAction = ActionFire -- the human is expected to fire a shot
      , turnNumber     = 0
      }
    gameState = case begin of
      HumanPlayer -> template { currentPlayer = humanPlayer, otherPlayer = aiPlayer }
      AIPlayer    -> template { currentPlayer = aiPlayer, otherPlayer = humanPlayer }
  return gameState

-------------------------------------------------------------------------------
-- * Helper Functions
-------------------------------------------------------------------------------

gridRange :: (Pos, Pos)
gridRange = ((0, 0), (w - 1, h - 1))
  where (w, h) = boardSize

shipAdmissible :: FleetPlacement -> ShipShape -> Bool
shipAdmissible fleet ship = rangeCheck && freeCheck where
  -- check if ship is completely inside grid
  rangeCheck     = L.all (inRange gridRange)
                 $ shipCoordinates 0 ship
  -- check if ship is not overlapping the safety margin of other ships
  freeCheck      = L.all (null . shipsAt fleet)
                 $ shipCoordinates 1 ship

-- | Calculates the position occupied by a ship including safety margin.
shipCoordinates :: HasShipShape s => Int -> s -> [Pos]
shipCoordinates margin (getShipShape -> ShipShape{..}) = 
  case shipOrientation of
    Horizontal -> [(x + i, y + d) | i <- [-margin..shipSize - 1 + margin], d <- [-margin..margin]]
    Vertical   -> [(x + d, y + i) | i <- [-margin..shipSize - 1 + margin], d <- [-margin..margin]]
  where (x, y) = shipPosition

-- | Returns the ships that contain the given positon.
shipsAt :: (Foldable f, HasShipShape s) => f s -> Pos -> [s]
shipsAt fleet (px, py) = filter containsP $ toList fleet where
  containsP (getShipShape -> ShipShape{..}) = case shipOrientation of
    Horizontal -> px >= sx && px < sx + shipSize && py == sy
    Vertical   -> px == sx && py >= sy && py < sy + shipSize
    where (sx, sy) = shipPosition

-- | Determine the time when a given ship was sunk.
shipSinkTime :: Ship -> Time
shipSinkTime ship = if isShipSunk ship then lastHitTime ship else mempty

-- | Determine the time when a given ship was sunk.
lastHitTime :: Ship -> Time
lastHitTime = fold . shipDamage -- let the maximum monoid take care of it

-- | If at the given position there is a completely sunk ship, this stores the turn number in which it was sunk.
sinkTime :: Fleet -> Array Pos Time
sinkTime fleet = buildArray gridRange $ \pos ->
  let isPosInShip = isJust . shipCellIndex pos
  in foldMap shipSinkTime $ Map.filter isPosInShip fleet

-- | Returns the zero-based index of a ship cell based on a global coordinate
shipCellIndex :: HasShipShape s => Pos -> s -> Maybe Int
shipCellIndex (px,py) (getShipShape -> ShipShape{..}) = case shipOrientation of
    Horizontal 
      | px >= sx && px < sx + shipSize && py == sy -> Just $ px - sx
    Vertical
      | px == sx && py >= sy && py < sy + shipSize -> Just $ py - sy
    _ -> Nothing
    where (sx, sy) = shipPosition

-- | Inflicts damage to the specified ship cell
damageShip :: Int -> Int -> Ship -> Ship
damageShip time i ship = ship { shipDamage = shipDamage ship // [(i, Time $ Just time)] }

allSunk :: Fleet -> Bool
allSunk = and . fmap isShipSunk

-- | Calculates how many ships of a given fleet are not sunk yet
numRemainingShips :: Fleet -> Int
numRemainingShips = Map.size . Map.filter (not . isShipSunk)

isDamaged :: Ship -> Bool
isDamaged = L.any (isJust . unwrapTime) . elems . shipDamage

isShipSunk :: Ship -> Bool
isShipSunk = L.all (isJust . unwrapTime) . elems . shipDamage

generateFleet :: FleetPlacement -> Fleet
generateFleet = Map.fromAscList . fmap newShip . zip [1..] where
  newShip (sID, shape) = (sID, Ship sID shape (listArray (0,shipSize shape-1) (repeat $ Time Nothing)))

numberShipsOfSize :: [Int] -> Int -> Int
numberShipsOfSize ships size = length $ filter (== size) ships

unsunkShips :: Fleet -> [Ship]
unsunkShips fleet = filter (not . isShipSunk) (Map.elems fleet)

sizesOfShips :: [Ship] -> [Int]
sizesOfShips = map (shipSize . shipShape)

humanPlayerState :: GameState a -> PlayerState
humanPlayerState GameState{..} = case playerType currentPlayer of
  HumanPlayer -> currentPlayer
  AIPlayer    -> otherPlayer

aiPlayerState    :: GameState a -> PlayerState
aiPlayerState GameState{..} = case playerType currentPlayer of
  HumanPlayer -> otherPlayer
  AIPlayer    -> currentPlayer

-------------------------------------------------------------------------------
-- * Turn Common
-------------------------------------------------------------------------------

-- | Checks whether the game is drawn.
-- It is drawn when it has timed out and both players 
-- have sunk the same number of ships.
isDrawn :: GameState a -> Bool
isDrawn game = isTimedOut game && remA == remB where
  remA = numRemainingShips $ playerFleet $ currentPlayer game
  remB = numRemainingShips $ playerFleet $ otherPlayer game

-- | Checks whether the game has timed out.
-- It is timed out when the number of shots has exceeded the
-- number rulesMaximumTurns from the rules.
isTimedOut :: GameState a -> Bool
isTimedOut game = curTurns >= maxTurns where
  curTurns = turnNumber game
  maxTurns = rulesMaximumTurns . gameRules $ game

-- | Calculates the number of remaining turns overall.
remainingTurns :: GameState a -> Int
remainingTurns game = maxTurns - curTurns where
  curTurns = turnNumber game
  maxTurns = rulesMaximumTurns . gameRules $ game

-- | Determines whether the countdown starts right now.
-- It starts when exactly countdownTurns turns remain.
isCountdownStart :: GameState a -> Bool
isCountdownStart game = remTurns == cdTurns where
  remTurns = remainingTurns game
  cdTurns = rulesCountdownTurns . gameRules $ game

-- | Switches the roles of active and passive player.
-- Every time the roles are switched, the turn number is increased.
switchRoles :: (MonadState (GameState a) m) => m ()
switchRoles = do
  modify(\g -> g
    { currentPlayer = otherPlayer g
    , otherPlayer = currentPlayer g
    })
  increaseTurnNumber

-- | increases the turn number. This function is called by `switchRoles`
-- and is not meant to be called directly.
increaseTurnNumber :: (MonadState (GameState a) m) => m ()
increaseTurnNumber = modify $ \gs -> gs {turnNumber = turnNumber gs + 1}

-- | The current player fires at the other player
-- Modifies the list of shots fired by the player and
-- inflicts damage to the other player's ship, if hit.
fireAt :: (MonadState (GameState a) m) 
       => Pos -> m HitResponse
fireAt pos = do
  self  <- gets currentPlayer
  other <- gets otherPlayer
  time <- gets turnNumber
  let remainingFleet = Map.filter (not . isShipSunk) (playerFleet other)
  result <- case shipsAt remainingFleet pos of
    [] -> return Water
    [ship] -> do
      let
        -- inflict damage to the ship
        Just idx = shipCellIndex pos ship
        newShip  = damageShip time idx ship
        -- replace ship
        newFleet = Map.insert (shipID ship) newShip (playerFleet other)
        other'   = other { playerFleet = newFleet }
      -- update other player
      modify (\gs -> gs { otherPlayer = other' })
      return $ if isShipSunk newShip
        then Sunk
        else Hit
    _ -> error $ "Multiple unsunk ships at position " ++ show pos ++ ". This shouldn't happen!"
  -- add this shot to history
  let self' = self { playerShots = Shot pos result time : playerShots self }
  modify (\gs -> gs{currentPlayer = self'})
  return result

-- | Executes the supplied turn.
executeShot :: (MonadState (GameState a) m)
            => HitResponse -> m Turn
executeShot result = do
  rules <- gets gameRules
  op <- gets otherPlayer
  handleDraw $ case result of
    Water -> Next
    Hit
      | rulesAgainWhenHit rules -> Again
      | otherwise               -> Next
    Sunk
      | allSunk $ playerFleet op -> Over
      | rulesAgainWhenHit rules  -> Again
      | otherwise                -> Next
  where
    handleDraw r = do
      timedOut <- gets isTimedOut
      return $ case r of
        Next | timedOut -> Over
        _               -> r

-------------------------------------------------------------------------------
-- * Turn AI
-------------------------------------------------------------------------------

-- | Performs a full AI turn. 
-- This function takes care of swapping the current player.
-- 1. according to the rules, the AI may fire once or more
-- 2. according to the rules, the AI may move one ship
aiTurn :: (MonadState (GameState a) m, MonadRandom m, AI a)
        => m Turn
aiTurn = do
    switchRoles
    result <- shots
    rules <- gets gameRules
    when (result /= Over && rulesMove rules) $ moveAI >>= executeMove
    switchRoles
    return result
  where
    shots = do
      result <- aiShot >>= executeShot
      case result of
        -- the AI player is allowed to shoot once more, when enabled
        Again -> shots
        _     -> return result

-- | Performs the enemies turn
-- assumes, that the enemy is the currentPlayer
aiShot :: (MonadState (GameState a) m, MonadRandom m, AI a) => m HitResponse
aiShot = do
  -- let the AI decide where to shoot
  ai <- gets aiState
  (pos, s) <- runStateT aiFire ai
  -- fire shot
  result <- fireAt pos
  -- give feedback to the AI
  (_, s')  <- runStateT (aiResponse pos result) s
  -- save modified AI state
  modify (\g -> g{aiState = s'})
  return result

-- | Lets the AI decide on a move
-- Assumes that the ai player is the currentPlayer
moveAI :: (MonadState (GameState a) m, MonadRandom m, AI a) 
     => m (Maybe (ShipID, Movement))
moveAI = do
  ai <- gets aiState
  fleet <- gets (playerFleet . currentPlayer)
  shots <- gets (playerShots . otherPlayer)
  (mov, s) <- runStateT (aiMove fleet shots) ai
  modify (\g -> g{aiState = s})
  return mov



-------------------------------------------------------------------------------
-- * Turn Human
-------------------------------------------------------------------------------

-- | This function executes the fire-part of the human's turn
-- It updates the "expectedAction" field of the state appropriately
humanTurnFire :: (MonadState (GameState a) m)
        => Pos -> m Turn
humanTurnFire target = do
  -- assert that the human is allowed to fire a shot
  ActionFire <- gets expectedAction
  -- setup common actions
  rules <- gets gameRules
  -- fire
  result <- fireAt target >>= executeShot
  when (result == Next) $ do
      -- update the action to "move" if appropriate
      humanFleet <- gets $ playerFleet . currentPlayer
      when (rulesMove rules && anyShipMovable rules humanFleet) $
        modify (\gs -> gs { expectedAction = ActionMove})
  return result

-- | Tries to move the human player's ship if pos is one of its endings.
-- Assumes that the human player is the currentPlayer
moveHuman :: (MonadState (GameState a) m) 
     => Maybe Pos -> m (Maybe (ShipID, Movement))
moveHuman pos = do
  -- assert the human is really expected to move a ship
  ActionMove <- gets expectedAction
  -- change expected action
  modify (\gs -> gs { expectedAction = ActionFire } )
  -- return real move
  case pos of
    Nothing -> return Nothing
    Just p  -> desiredMove p `liftM` gets (playerFleet . currentPlayer)

-- | Find out which ship the player wants to move into which direction.
desiredMove :: Pos -> Fleet -> Maybe (ShipID, Movement)
desiredMove pos fleet = do 
  Ship{..} <- case shipsAt remainingFleet pos of
    []     -> Nothing
    [ship] -> Just ship
    _      -> error $ "Multiple unsunk ships at positon " ++ show pos ++ ". This shouldn't happen!"
  let 
    (x,y) = shipPosition shipShape
    size  = shipSize shipShape
  case shipOrientation shipShape of
    Horizontal 
      | pos == (x, y)            -> Just (shipID, Forward)
      | pos == (x + size - 1, y) -> Just (shipID, Backward)
    Vertical
      | pos == (x, y)            -> Just (shipID, Forward)
      | pos == (x, y + size - 1) -> Just (shipID, Backward)
    _ -> Nothing
  where remainingFleet = Map.filter (not . isDamaged) fleet

-------------------------------------------------------------------------------
-- * Moving Ships
-------------------------------------------------------------------------------

-- | executes a move for the current player
executeMove :: (MonadState (GameState a) m)
     => Maybe (ShipID, Movement) -> m ()
executeMove move = do
  curPlayer <- gets currentPlayer
  let fleet = playerFleet curPlayer
  case move of
    Nothing                 -> return ()
    Just (shipID, movement) -> case Map.lookup shipID fleet of
      Just ship -> unless (isDamaged ship) $ do
        time <- gets turnNumber
        let
          newFleet   = moveShip ship movement fleet
          curPlayer' = curPlayer 
            { playerFleet = newFleet
            , playerMoves = ShipMove shipID movement time : playerMoves curPlayer 
            }
        modify (\gs -> gs { currentPlayer = curPlayer' })
      Nothing -> return ()

-- | Only moves the ship if it complies with the given rules.
moveShip :: Ship -> Movement -> Fleet -> Fleet
moveShip ship movement fleet = 
  if isMovable movement fleet ship
    then uncheckedMoveShip ship movement fleet
    else fleet

-- | Like moveShip but without movability check; needed for replay.
uncheckedMoveShip :: Ship -> Movement -> Fleet -> Fleet
uncheckedMoveShip ship movement = 
  Map.adjust (\s -> s{shipShape = newShape}) (shipID ship)
    where newShape = movedShipShape movement (shipShape ship)

-- | Checks whether a ship can be moved.
isMovable :: Movement -> Fleet -> Ship -> Bool
isMovable movement fleet ship =
       shipAdmissible otherShips newShape
    && not (isDamaged ship) 
  where
    newShape = movedShipShape movement (shipShape ship)
    otherShips = map (shipShape . snd)
               . Map.toAscList
               . Map.filter (not . isShipSunk) -- this ship can move over sunk ships
               . Map.delete (shipID ship)
               $ fleet

-- | Returns whether any ship in the given fleet is movable.
anyShipMovable :: Rules -> Fleet -> Bool
anyShipMovable rules fleet = rulesMove rules 
    && (anyForward || anyBackward)
  where
    anyForward  = or $ fmap (isMovable Forward fleet) fleet
    anyBackward = or $ fmap (isMovable Backward fleet) fleet

-- | Ship after movement was made.
movedShipShape :: Movement -> ShipShape -> ShipShape
movedShipShape movement ship = case (shipOrientation ship, movement) of
  (Horizontal, Forward)  -> ship {shipPosition = (x - 1, y)}
  (Horizontal, Backward) -> ship {shipPosition = (x + 1, y)}
  (Vertical, Forward)    -> ship {shipPosition = (x, y - 1)}
  (Vertical, Backward)   -> ship {shipPosition = (x, y + 1)}
  where (x,y) = shipPosition ship