James McNeill translated the ancient computer game "Hamurabi" from BASIC to Haskell.
This article will discuss the ease of adding a simple "undo" feature to the modular Haskell implementation with the List monad transformer.
The version in BASIC was 121 lines long, while the Haskell version is 261 lines. At first this seems bad for Haskell. Let's analyze it a bit more:
Without imports, type definitions, and blank lines, the Haskell version weights at 200 lines. The BASIC program's lines are more dense - line numbers are used as labels, while in Haskell one tends to give a full line for the name of a do-block function.
All in all the difference doesn't seem very big.
The Haskell code has one big advantage: it is modular. I'll demonstrate by showing the diff to adding a simple "undo" mechanism using backtracking with the list monad transformer from my generator (list monad and related utilities) package.
The program (with annotated diffs in big font):
{-# LANGUAGE FlexibleContexts, TemplateHaskell #-}
-- to compile: ghc --make hammurabi.hs -package transformers -hide-package mtl
-- Converted from the original FOCAL program and modified for Edusystem 70 by David Ahl, Digital
-- Modified for 8K Microsoft BASIC by Peter Turnbull
-- Ported to Haskell by James McNeill
-- Refactored to use MaybeT and StateT by Yair Chuchem
import Control.Monad (forM, forever, join, liftM, liftM2, liftM3, mzero, when)
import Control.Monad.ListT
import Control.Monad.Trans
import Control.Monad.Trans.Maybe
import Control.Monad.Trans.State (StateT, runStateT)
import Data.Accessor.MonadState (get, modify, set)
import Data.Accessor.Template
import Data.Function
import Data.List.Class (execute, repeat, takeWhile)
import Data.Ratio
import Random
import IO
import Text.Printf
import Prelude hiding (repeat, takeWhile)
data GameState = GameState {
 year_         :: Integer,
 people_       :: Integer,
 food_         :: Integer, -- bushels
 land_         :: Integer, -- acres
 landPrice_    :: Integer, -- bushels per acre
 totalDeaths_  :: Integer,
 cumDeathRate_ :: Rational,
 rng_          :: StdGen
} deriving (Show)
$(deriveAccessors ''GameState)
data Orders = Orders {
 acresToPlant   :: Integer,
 bushelsForFood :: Integer
} deriving (Show)
data Results = Results {
 peopleStarved      :: Integer,
 peopleDiedOfPlague :: Integer,
 peopleBorn         :: Integer,
 bushelsEatenByRats :: Integer,
 bushelsPerAcre     :: Integer
} deriving (Show)
main :: IO ()
main = do
 putStrLn "Try your hand at governing ancient Sumeria successfully for a 10-year term of\noffice."
 start <- liftM initialState getStdGen
-- Before Undo:
 runMaybeT . (`runStateT` start) $ do
-- With Undo:
 runMaybeT . execute . (`runStateT` start) $ do
-- Same:
    showResults initialResults
   forever mainLoopIter
 putStrLn "So long for now."
-- Before Undo:
type Hammurabi = StateT GameState (MaybeT IO)
quit :: Hammurabi a
quit = mzero
-- With Undo:
type Hammurabi = StateT GameState (ListT (MaybeT IO))
-- ListT is an instance of mtl's MonadTrans.
-- here we have to use transformers' because of using Data.Accessor
instance MonadTrans ListT where
 lift = ListT . liftM (`Cons` mzero)
instance MonadIO m => MonadIO (ListT m) where
 liftIO = lift . liftIO
quit :: Hammurabi a
quit = lift $ lift mzero
-- Same:
mainLoopIter :: Hammurabi ()
mainLoopIter = do
 set landPrice =<< useRandom (randomR (17, 26))
 peopleStart <- get people
 resultsOut <- applyOrders =<< readOrders
 showResults resultsOut
 let numStarved = peopleStarved resultsOut
 when (numStarved % peopleStart > 0.45) $ do
   liftIO $ printf "You starved %d people in one year!\n%s" numStarved finkMessage
   quit
 join . liftM2 (when . (== 10)) (get year) . return $ do
   finalReport
   quit
useRandom :: (StdGen -> (a, StdGen)) -> Hammurabi a
useRandom func = do
 (res, rngOut) <- fmap func $ get rng
 set rng rngOut
 return res
showResults :: Results -> Hammurabi ()
showResults results = do
 liftIO $ printf "\nHamurabi:  I beg to report to you,\n"
 y <- get year
 liftIO $ printf "In year %d, %d people starved, %d came to the city.\n" y (peopleStarved results) (peopleBorn results)
 when (peopleDiedOfPlague results > 0) . liftIO . putStrLn $ "A horrible plague struck!  Half the people died."
 liftIO . printf "Population is now %d.\n" =<< get people
 liftIO . printf "The city now owns %d acres.\n" =<< get land
 liftIO $ printf "You harvested %d bushels per acre.\n" (bushelsPerAcre results)
 liftIO $ printf "Rats ate %d bushels.\n" (bushelsEatenByRats results)
 liftIO . printf "You now have %d bushels in store.\n" =<< get food
-- :(
killPeople :: Integer -> Hammurabi ()
killPeople n = do
 modify people (+ negate n)
 modify totalDeaths (+ n)
applyOrders :: Orders -> Hammurabi Results
applyOrders orders = do
 modify year (+ 1)
 peopleInit <- get people
 harvestYield <- useRandom $ randomR (1, 6)
 let
   starved = max 0 $ peopleInit - (bushelsForFood orders `div` 20)
   calcEatenByRats r f
     | ((r `mod` 2) == 1) = 0
     | otherwise = f `div` r
   calcDiedOfPlague :: Double -> Integer -> Integer
   calcDiedOfPlague r p = if r >= 0.15 then 0 else p `div` 2
   calcBorn r l b = 1 + ((r * (20 * l + b)) `div` (peopleInit * 100))
   bushelsHarvested = harvestYield * acresToPlant orders
 killPeople starved
 modify cumDeathRate (+ starved % peopleInit)
 modify food (+ negate (acresToPlant orders `div` 2))
 modify food (+ bushelsHarvested)
 eatenByRats <- liftM2 calcEatenByRats (useRandom (randomR (1, 6))) (get food)
 modify food (+ negate eatenByRats)
 born <- liftM3 calcBorn (useRandom (randomR (1, 6))) (get land) (get food)
 modify people (+ born)
 diedOfPlague <- liftM2 calcDiedOfPlague (useRandom random) (get people)
 killPeople diedOfPlague
 return Results {
   peopleStarved = starved,
   peopleDiedOfPlague = diedOfPlague,
   peopleBorn = born,
   bushelsEatenByRats = eatenByRats,
   bushelsPerAcre = harvestYield
   }
readNum ::
 String -> String ->
 [(Hammurabi Integer, Hammurabi ())] ->
 Maybe Integer -> Hammurabi Integer
readNum units purpose limits idealN = do
 lims <-
   forM limits $ \(alim, amsg) -> do
     r <- alim
     return (r, amsg)
 let
   maxN = minimum $ map fst lims
   defaultN = maybe maxN (min maxN) idealN
 case maxN of
   0 -> return 0
   _ ->
     fix $ \resume -> do
-- Before Undo:
        liftIO . putStr $
         "How many " ++ units ++
         " do you wish to " ++ purpose ++
         " (0-" ++ show maxN ++ ")? [" ++ show defaultN ++ "] "
       liftIO $ hFlush stdout
       line <- liftIO getLine
-- With Undo:
        line <-
         lift . takeWhile (/= "undo") $ do
           repeat () -- backtracking would be to this point
           liftIO . putStr $
             "How many " ++ units ++
             " do you wish to " ++ purpose ++
             " (0-" ++ show maxN ++ ")? [" ++ show defaultN ++ "] "
           liftIO $ hFlush stdout
           liftIO getLine
-- Same:
        case maybeRead line of
         Nothing -> return defaultN
         Just n
           | n < 0 -> do
             liftIO $ putStrLn abortMessage
             quit
           | n < maxN -> do
             snd . head $ filter ((< n) . fst) lims
             resume
           | otherwise -> return n
maybeRead :: Read a => String -> Maybe a
maybeRead s =
 case reads s of
   [(x, str)] | all (== ' ') str -> Just x
   _ -> Nothing
readOrders :: Hammurabi Orders
readOrders = do
 let
   thinkAgain item suf = do
     x <- get item
     liftIO $ printf
       "Hammurabi: Think again. You have only %d %s.  Now then,\n"
       x suf
   thinkAgainFood = thinkAgain food "bushels of grain"
   tendFieldsMsg =
     liftIO . printf
       "But you have only %d people to tend the fields.  Now then,\n"
        =<< get people
 liftIO . printf "Land is trading at %d bushels per acre\n" =<< get landPrice
 buyLand <-
   readNum "acres" "buy"
   [(liftM2 div (get food) (get landPrice), thinkAgainFood)]
   (Just 0)
 sellLand <-
   readNum "acres" "sell"
   [(if buyLand > 0 then return 0 else get land, thinkAgain land "acres")]
   (Just 0)
 let landDiff = buyLand - sellLand
 modify land (+ landDiff)
 modify food . (+) . negate . (* landDiff) =<< get landPrice
 feed <-
   readNum "bushels" "feed your people"
   [(get food, thinkAgainFood)]
   . Just . (* 20) =<< get people
 modify food (+ negate feed)
 plant <-
   readNum "acres" "plant with seed"
   [(get land, thinkAgain land "acres")
   ,(fmap (* 2) (get food), thinkAgainFood)
   ,(fmap (* 10) (get people), tendFieldsMsg)]
   Nothing
 return $ Orders plant feed
finalReport :: Hammurabi ()
finalReport = do
 numYears <- get year
 avgDeathRate <- fmap (/ (numYears % 1)) $ get cumDeathRate
 numPeople <- get people
 acresPerPerson <- fmap (% numPeople) $ get land
 numHaters <- useRandom $ randomR (0, (numPeople * 4) `div` 5)
 let
   comments
     | avgDeathRate > 0.33 || acresPerPerson < 7 = finkMessage
     | avgDeathRate > 0.1 || acresPerPerson < 9 =
       "Your heavy-handed performance smacks of Nero and Ivan IV.\n" ++
       "The people (remaining) find you an unpleasant ruler, and,\n" ++
       "frankly, hate your guts!\n"
     | avgDeathRate > 0.03 || acresPerPerson < 10 =
       "Your performance could have been somewhat better, but\n" ++
       "really wasn't too bad at all. " ++
       show numHaters ++ " people would\n" ++
       "dearly like to see you assassinated but we all have our\n" ++
       "trivial problems.\n"
     | otherwise =
       "A fantastic performance!!!  Charlemagne, Disraeli, and\n" ++
       "Jefferson combined could not have done better!\n"
 td <- get totalDeaths
 liftIO . putStrLn $
   "In your " ++ show numYears ++ "-year term of office, " ++
   show (round ((100%1) * avgDeathRate) :: Integer) ++ " percent of the\n" ++
   "population starved per year on average, i.e., " ++
   "a total of " ++ show td ++ " people died!!\n" ++
   "You started with 10 acres per person and ended with " ++
   show (round acresPerPerson :: Integer) ++ " acres per person.\n" ++
   comments
initialState :: StdGen -> GameState
initialState r = GameState {
 year_ = 0,
 people_ = 100,
 food_ = 2800,
 land_ = 1000,
 landPrice_ = 0,
 totalDeaths_ = 0,
 cumDeathRate_ = 0,
 rng_ = r }
initialResults :: Results
initialResults = Results 0 0 5 200 3
abortMessage :: String
abortMessage = "Hammurabi: I cannot do what you wish!\nGet yourself another steward!!!!!"
finkMessage :: String
finkMessage =
 "Due to this extreme mismanagement you have not only\n" ++
 "been impeached and thrown out of office but you have\n" ++
 "also been declared 'National Fink' !!\n"- It's a nice game. If you play it - only use undo to correct typos!
- This is a very basic undo. It doesn't support redo etc. I'm not endorsing this way of implementing undo. The purpose was just to show an example of what can be done with the list monad transformer.
- There is a small problem with Haskell libraries. I had to named my module Control.Monad.ListT instead of Control.Monad.List because that one is taken by mtl (and imho doesn't provide what a list monad should). There are two monad-transformers packages (mtl and transformers) and both are being used. I had to use both because I chose to support mtl but then Data.Accessor uses transformers. Perhaps the solution is to break libraries to smaller parts. Then it would be easy for the community to agree on those parts. That way I could have also used the Control.Monad.List name. Perhaps it means I should break my library to parts as well.