Haskell Quiz/TicTacToe/Solution Abhinav

From HaskellWiki
Jump to navigation Jump to search
{-
  A learning tic-tac-toe player in Haskell. It learns the game
  by playing against itself repeatedly.
  It can play against humans too!

  A solution to rubyquiz 11 (http://rubyquiz.com/quiz11.html).

  Copyright 2012 Abhinav Sarkar <abhinav@abhinavsarkar.net>
-}

{-# LANGUAGE BangPatterns #-}

module TicTacToe where

import Data.List (sort, nub, maximumBy)
import Data.List.Split (chunk)
import Data.Ord (comparing)
import System.Random (Random, StdGen, randomR, newStdGen, split)
import System.IO (hSetBuffering, stdin, stdout, BufferMode(..))
import Control.Monad.State (State, get, put, runState, evalState)
import qualified Data.Map as M

-- Randomness setup

type RandomState = State StdGen

getRandomR :: Random a => (a, a) -> RandomState a
getRandomR limits = do
  gen <- get
  let (val, gen') = randomR limits gen
  put gen'
  return val

randomChoose :: [a] -> RandomState a
randomChoose list = do
  i <- getRandomR (0, length list - 1)
  return $ list !! i

toss :: RandomState Bool
toss = randomChoose [True, False]

-- Board setup

data Move = Nought | Cross deriving (Eq, Ord)

data CellState = Filled Move | Empty deriving (Eq, Ord)

data Cell = Cell {cellPos :: Int, cellState :: CellState} deriving (Eq, Ord)

type Board = [Cell]

type Run = [Board]

data Result = Win | Loss | Draw | Unfinished deriving (Eq, Show)

instance Show Move where
  show Nought = "O"
  show Cross  = "X"

instance Show CellState where
  show (Filled move) = show move
  show Empty = "~"

instance Show Cell where
  show c = show $ cellState c

otherMove :: Move -> Move
otherMove Nought = Cross
otherMove Cross = Nought

otherResult :: Result -> Result
otherResult Draw = Draw
otherResult Loss = Win
otherResult Win = Loss

emptyBoard :: Board
emptyBoard = map (flip Cell Empty) [0..8]

printBoard :: Board -> IO ()
printBoard board = putStrLn "" >> (mapM_ print . chunk 3 $ board)

makeMove :: Int -> Move -> Board -> Board
makeMove pos move board =
  let (l, r) = splitAt pos board
  in l ++ [Cell pos (Filled move)] ++ tail r

diags :: Board -> [[Cell]]
diags board =
  [[board !! 0, board !! 4, board !! 8],
   [board !! 2, board !! 4, board !! 6]]

nextBoards :: Move -> Board -> [(Int, Board)]
nextBoards move board =
  map ((\p -> (p, makeMove p move board)) . cellPos)
  $ filter (\c -> cellState c == Empty) board

isWin :: Move -> Board -> Bool
isWin move board =
  or [any isStrike $ chunk 3 $ map cellState board,
      any isStrike $ chunk 3 $ map cellState $ rotateBoard board,
      any isStrike $ map (map cellState) $ diags board]
  where
    isStrike = (== replicate 3 (Filled move))

result :: Move -> Board -> Result
result move board
  | isWin move board                 = Win
  | isWin (otherMove move) board     = Loss
  | Empty `elem` map cellState board = Unfinished
  | otherwise                        = Draw

translateBoard :: [Int] -> Board -> Board
translateBoard idxs board =
  map (\(i, ri) -> Cell i $ cellState $ board !! ri) $ zip [0..8] idxs

rotateBoard, xMirrorBoard, yMirrorBoard :: Board -> Board
rotateBoard  = translateBoard [6,3,0,7,4,1,8,5,2]
xMirrorBoard = translateBoard [2,1,0,5,4,3,8,7,6]
yMirrorBoard = translateBoard [6,7,8,3,4,5,0,1,2]

rotateBoardN :: Board -> Int -> Board
rotateBoardN board n = foldl (\b _ -> rotateBoard b) board [1..n]

-- Player setup

class Player a where
  playerMove :: a -> Move
  play :: a -> Board -> (a, Board)
  improvePlayer :: a -> Result -> Run -> a

-- play a match between two players
playMatch :: (Player p1, Player p2) => p1 -> p2 -> (Result, Run, p1, p2)
playMatch player1 player2 = playMatch_ player1 player2 emptyBoard

playMatch_ :: (Player p1, Player p2) => p1 -> p2 -> Board -> (Result, Run, p1, p2)
playMatch_ player1 player2 board =
  case result (playerMove player1) board of
    Unfinished -> let
      (player1', board') = play player1 board
      in case result (playerMove player1) board' of
        Unfinished -> let
          (res', run, player2', player1'') = playMatch_ player2 player1' board'
          in (otherResult res', board' : run, player1'', player2')
        res -> (res, [], player1', player2)
    res -> (res, [], player1, player2)

-- play multiple matches between two players
playMatches :: (Player p1, Player p2) => Int -> p1 -> p2 -> ([(Result, Run)],p1, p2)
playMatches times player1 player2 =
  foldl (\(matches, p1, p2) _ ->
    let
      (res, run, p1', p2') = playMatch p1 p2
      p1'' = improvePlayer p1' res run
      p2'' = improvePlayer p2' (otherResult res) run
    in  ((res, run) : matches, p1'', p2''))
  ([], player1, player2) [1..times]

-- RandomPlayer setup

-- play randomly. choose a random move
randomPlay :: Move -> Board -> RandomState Board
randomPlay move board = randomChoose (map snd $ nextBoards move board)

data RandomPlayer = RandomPlayer Move StdGen deriving (Show)

instance Player RandomPlayer where
  playerMove (RandomPlayer move _) = move
  play (RandomPlayer move gen) board =
    let
      (board', gen') = runState (randomPlay move board) gen
    in (RandomPlayer move gen', board')
  improvePlayer player _ _ = player

-- LearningPlayer setup

type Memory = M.Map Board (Int, Int, Int)

-- boards equivalent to this board
eqvBoards :: Board -> [Board]
eqvBoards board = nub . sort $
  board : map (rotateBoardN board) [1..3] ++ [xMirrorBoard board, yMirrorBoard board]

data LearningPlayer = LearningPlayer Move Memory StdGen deriving (Show)

-- play using the strategy learned till now
learningPlay :: LearningPlayer -> Board -> (LearningPlayer, Board)
learningPlay (LearningPlayer move mem gen) board = let
  next = map snd $ nextBoards move board
  in case filter (isWin move) next of
    (winBoard:_) -> (LearningPlayer move mem gen, winBoard)
    [] -> let
      otherNext = nextBoards (otherMove move) board
      in case filter (isWin (otherMove move) . snd) otherNext of
        ((pos,_):_) -> (LearningPlayer move mem gen, makeMove pos move board)
        [] -> let
          scores = map (\b -> (b, boardScore b mem)) $ next
          (board', (w, _, d)) = maximumBy (comparing (calcScore . snd)) scores
          in if w /= 0
             then (LearningPlayer move mem gen, board')
             else let
               ((rBoard, _), gen') = runState (randomChoose scores) gen
             in (LearningPlayer move mem gen', rBoard)
  where
    boardScore board' mem =
      foldl (\score b' -> sumScores score $ M.findWithDefault (0, 0, 0) b' mem)
            (0, 0, 0) (eqvBoards board')
    sumScores (w, l, d) (w', l', d') = (w + w', l + l', d + d')

calcScore :: (Int, Int, Int) -> Double
calcScore (w, l, d) = fromIntegral w + fromIntegral d * 0.5 - fromIntegral l

-- learn strategy from the run
learnFromRun :: Result -> Run -> Memory -> Memory
learnFromRun res run mem = let
  score = incrementScore res (0, 0, 0)
  mem' = foldl (\m b -> M.insertWith (\_ -> incrementScore res) b score m)
               mem run
  in mem'
  where
    incrementScore res (w, l, d) =
      case res of
        Win  -> (w + 1, l, d)
        Loss -> (w, l + 1, d)
        Draw -> (w, l, d + 1)

instance Player LearningPlayer where
  playerMove (LearningPlayer move _ _) = move
  play = learningPlay
  improvePlayer (LearningPlayer move mem gen) res run =
    LearningPlayer move (learnFromRun res run mem) gen

-- play two LearningPlayers against each other to learn strategy
learnedPlayer :: Move -> StdGen -> LearningPlayer
learnedPlayer move gen = let
  (gen1, gen2) = split gen
  p1 = LearningPlayer move M.empty gen1
  p2 = LearningPlayer (otherMove move) M.empty gen2
  (_, p1', p2') = playMatches 1000 p1 p2
  in p1'

-- Play against human

-- play a player against a human. human enters moves from prompt.
playHuman :: Player p => p -> Board -> IO ()
playHuman player board = do
  printBoard board
  case result (playerMove player) board of
    Unfinished -> do
      putStr "Move? "
      pos <- fmap (decr . read) getLine
      if pos < 0 || pos > 8
      then do
        putStrLn "Invalid Move"
        playHuman player board
      else
        case cellState (board !! pos) of
          Filled _ -> do
            putStrLn "Invalid Move"
            playHuman player board
          Empty -> let
            board' = makeMove pos Nought board
            in case result (playerMove player) board' of
              Unfinished -> let
                (player', board'') = play player board'
                in playHuman player' board''
              res -> do
                printBoard board'
                putStrLn ("Your " ++ show (otherResult res))
    res -> putStrLn ("Your " ++ show (otherResult res))
  where decr x = x - 1

main :: IO ()
main = do
  hSetBuffering stdin LineBuffering
  hSetBuffering stdout NoBuffering
  gen <- newStdGen
  putStrLn "Learning ..."
  let !player = learnedPlayer Cross gen
  putStrLn "Learned"
  putStrLn "Tossing for first move"
  let t = evalState toss gen
  if t
  then do
    putStrLn "You win toss"
    playHuman player emptyBoard
  else do
    putStrLn "You lose toss"
    let (player', board) = play player emptyBoard
    playHuman player' board

Description: The program remembers all the previous board configurations and their final outcomes. It then choose among the next board configurations the one which scores highest depending on the previous outcomes from that configuration.

Source: https://github.com/abhin4v/rubyquiz/blob/master/TicTacToe.hs