halma/src/Game/Halma/AI/Competitive.hs

Summary

Maintainability
Test Coverage
module Game.Halma.AI.Competitive
  ( aiMove
  ) where

import Game.Halma.Board
import Game.Halma.Rules
import Game.Halma.AI.Base

import Data.List (sortOn)

-- | The perspective of the first team as opposed to the second team.
type Perspective = (Team, Team)

flipPersp :: Perspective -> Perspective
flipPersp (t0, t1) = (t1, t0)

rate :: Perspective -> HalmaBoard -> Rating
rate (t0, t1) board = rateTeam t0 board `against` rateTeam t1 board
  where
    (WinIn n) `against` _ = WinIn n
    _ `against` (WinIn n) = LossIn n
    (Rating r0) `against` (Rating r1) = Rating (r0 - r1)
    _ `against` _ = error "unexpected team rating indicating loss"

aiMove :: RuleOptions -> HalmaBoard -> Perspective -> Move
aiMove opts board persp =
  snd $ prunedMinMaxSearch 3 opts board persp Nothing

-- | Find the best move or one that reaches the given bound.
prunedMinMaxSearch
  :: Int
  -> RuleOptions
  -> HalmaBoard
  -> Perspective
  -> Maybe Rating
  -> (Rating, Move)
prunedMinMaxSearch depth opts board persp mBound =
  go Nothing allOptions
  where
    allOptions =
      sortIfUseful $ do
        move <- allLegalMoves opts board (fst persp)
        pure (rate persp (outcome board move), move)
    sortIfUseful =
      if depth <= 2 then
        id
      else
        sortOn (flipRating . fst)
    go :: Maybe (Rating, Move) -> [(Rating, Move)] -> (Rating, Move)
    go Nothing (option@(rating, _move):options) =
      if isWin rating then
        option
      else
        go (Just $ nextLevel option Nothing) options
    go (Just (currentMax, bestMove)) [] = (currentMax, bestMove)
    go (Just (currentMax, bestMove)) (option@(rating, move):options) =
      if isWin rating then
        option
      else if newRating <= currentMax then
        go (Just (currentMax, bestMove)) options
      else if boundReached newRating then
        (newRating, move)
      else
        go (Just (newRating, move)) options
      where newRating = fst $ nextLevel option (Just currentMax)
    go Nothing [] = error "no legal moves found"
    nextLevel (rating, move) mCurrentMax =
      let newRating =
            if depth <= 1 then
              rating
            else
              pushRating $ flipRating $ fst $
              prunedMinMaxSearch
                (depth-1) opts (outcome board move)
                (flipPersp persp) (fmap flipRating mCurrentMax)
      in (newRating, move)
    boundReached dep = maybe False (dep >=) mBound

isWin :: Rating -> Bool
isWin rating =
  case rating of
    WinIn _ -> True
    _ -> False

flipRating :: Rating -> Rating
flipRating rating =
  case rating of
    WinIn n -> LossIn n
    LossIn n -> WinIn n
    Rating r -> Rating (-r)

pushRating :: Rating -> Rating
pushRating rating =
  case rating of
    WinIn n -> WinIn (n+1)
    LossIn n -> WinIn (n+1)
    Rating r -> Rating r