Creatable a => a -> IO b

Haskellと数学とちょびっと音楽

Haskellでポーカーを作ろう〜第六回 CPU対戦機能を付けよう〜

はいはいどうも、台風シーズンですね。
小学生の頃とか、台風はなんかドキドキするので好きでした。

今も好きですが、いろいろとアレがアレして、昔ほど手放しで楽しめないです。
おとなになんか、なりたくなかった。

はい、どうもパスタ大好きちゅーんさんです。

このエントリは、ちゅーんさんによるポーカー開発連載記事の第六回目です。
過去のエントリはこちら

第一回 リストのシャッフルとカードの定義
第二回 ポーカー・ハンドの判定をする 前編
第三回 ポーカー・ハンドの判定をする 中編
第四回 ポーカー・ハンドの判定をする 後編
第五回 カードの入れ替え処理を作る

とゆーわけで、今回は、前回作ったプロトタイプに、CPUと対戦する機能をつけていきます。

仕様を考える

といっても、この段階でAIと呼べるほどがっつりしたものを作るわけではないです。

最終的には個性的なAIを作って、CPU同士やプレイヤー間でちょっとした駆け引きが行われるような感じに出来ると良いのですが、 現段階ではまだ簡単でも動く事が重要です。

というわけで、作っていくわけですけども、オブジェクト指向に慣れている方だと、 その前に対戦相手のAIを切り替えたりできるように、抽象クラスやインターフェイスのようなものを考えた方が良いと思われるかもしれません。

しかしHaskellの場合は、そこまで深く考える必要はないのです。何故でしょう?

今日作るもの

まず、「入れ替えるカードを判断する」処理というのは、 「手札からいらないカードを判定する」という処理と考える事が出来ます。

つまり、次のような関数が用意できれば良いわけですね。

aiSelectDiscards :: Hand -> DiscardList

本日の目標はこの関数と勝敗判定処理を実装し、それをベースに前回作ったプロトタイプを改造する事です。

AIの仕様の構想

Haskellには関数同士を糊付けする手段が沢山あるため、ひとまず必要な事がわかっている関数は作ってしまえば良い、 という事は以前も述べましたが、かといって闇雲に作っていってもゴールにたどり着くのが大変なので、 予めどのように構成していくのか、雰囲気だけでも考えておきましょう。

尚、ここで書くことはあくまで「現段階で考えられる方向性」ですので、 大幅に変更される可能性がある事をご了承ください。

まず、aiSelectDiscardsの型定義では、ちゃんとしたAIを作るためには不十分であると言えます。

同じスートのカードが4枚揃っていた場合、 残りの1枚だけ替えてフラッシュに賭けるか、それとも全換えしてワンペア以上の役に賭けるかを、 場の「状況」や「気まぐれ」等によって判断する必要がありますね。

対人のドローポーカーにおいて、相手が「何枚カードを捨てたか」というのは、 相手のハンドの強さを判断するための重要な情報になります。 そのため、「気まぐれ」にブラフとしてノーペアなのにわざと二枚残してカードを入れ替える事もあるでしょう。

場の「状況」等をAiHintという型に纏めると想定し、「気まぐれ」な判断をさせるため乱数(副作用)を許すような型を考えると、 最終的に捨て札を選択するAIの処理は次のような型を持った関数になると考えられます。

AiHint -> Hand -> IO DiscardList

IOは何でも出来てしまうので、危険だと感じる方も多いかもしれません。 より安全な型定義をする事も出来ますが、 やや高度な話になってきてしまうので、今回はIOで我慢する事にしましょう。

どんどん型を揃える

上記の定義では、AiHintを第一引数にしてありますので、この部分は簡単に部分適用できますね。
するとHand -> IO DiscardListという型を得る事ができます。

あー、そういえば 前回の最後のほうで、だだだーっとお見せしたプロトタイプの中に、次のような関数がありました。

inputDisuse :: Hand -> IO DiscardList
inputDisuse hand = do
  ...

これは、プレイヤーから捨て札の入力を受ける処理です。

この関数を元に、次のような関数を作る事ができまして・・・

inputDisuse' :: AiHint -> Hand -> IO DiscardList
inputDisuse' _ = inputDisuse

このように、先ほど想定したAIと型を合わせる事が可能です。 (尚、この関数は確実に使うといえるわけでは無いので、まだ作らなくても良いです)

Haskell関数型プログラミング言語ですから、この型を持った関数を内包したデータ構造を定義したり、 この型を持った関数を引数に持った高階関数を定義する事も簡単に出来ますね。

つまる所、ユーザーだろうがAIだろうが、プレイヤーが捨て札を選別する処理については、 AiHint -> Hand -> IO DiscardListの型になるように実装すれば全て同じように扱う事ができます。

結果的に、「必要な処理」の型を考える事によって、自動的に何種類ものAIを統一的に扱うための手段を得る事ができました。
それどころか、ユーザーの入力も同じ仕組みの上で扱えるというおまけも付いてきましたね。

Haskellではこのように、普段から型を揃えるクセを付ける事によって、 明記せずともオブジェクト指向ポリモーフィズムを駆使したのと同様の道具立てを得る事が出来るのです。

捨て札を選択する

さて、ふわふわっと方向性について考えた所で、今回作る関数の型を再掲します。

aiSelectDiscards :: Hand -> DiscardList

モジュールの整理は次回やる事として、今回もMain.hsにずらずらと書いていく事にしましょう。

ひとまずそれっぽく動くようにするのが目的なので、 今回は「役が確定している場合のみ残し、それ以外は捨てる」という方針で作っていきます。

最後には色々な思考パターンを持ったAIを作るのですから、今回作る「単純な判定処理」が無駄になる事はないでしょう。

番号の揃っているカードを除外する

まず、ワンペアやツーペア、スリーカードやフォーカードを判定します。

同じ番号が揃っている場合、そのカードは残しておきたいわけですから、捨て札からは除外する必要がありますね。 除外するには、まず揃っているカードを手札から抽出しなくてはいけないわけですから、手始めに次のような関数を作ります。

allNOfKinds :: Hand -> [Card]

返り値は、同じ番号のカードが2枚以上揃っているすべてのカードです。 例えば[D2_,S2_,CJ_,SJ_,HA_]が与えられた場合は、[D2_,S2_,CJ_,SJ_]を返し、 [D8_,C8_,S8_,DJ_,CA_]が与えられた場合は、[D8_,C8_,S8_]を返します。

この関数を実装するには、第三回で作ったnOfKindHint :: Int -> Hand -> Maybe [[Card]]関数が役に立ちそうです。 nOfKindHintは第二引数で指定した手札から、第一引数で指定したn枚組の全カードを返すのでしたね。

さらに、Data.MaybeモジュールにあるcatMaybes関数と、二重のリストを一重に押しつぶすconcat関数も使います。

ghci> :t catMaybes 
catMaybes :: [Maybe a] -> [a]
ghci> catMaybes [Just 1, Nothing, Just 2, Just 3]
[1,2,3]
ghci> :t concat
concat :: [[a]] -> [a]
ghci> concat [[1,2],[3,4,5]]
[1,2,3,4,5]

というわけで、必要な道具は次の2つです。

nOfKindHint :: Int -> Hand -> Maybe [[Card]]
catMaybes :: [Maybe a] -> [a]
concat :: [[a]] -> [a]

型を合わせていく感覚にも、そろそろ慣れてきた頃でしょうし、詳細な説明は省略し、 allNOfKindsの返り値になる[Card]という型を構成していく流れをだだーっと羅列していきましょう。

hand :: Hand とする

① nOfKindHint 2 hand :: Maybe [[Card]]
② [nOfKindHint 2 hand, nOfKindHint 3 hand, nOfKindHint 4 hand] :: [Maybe [[Card]]]
③ catMaybes [nOfKindHint 2 hand, nOfKindHint 3 hand, nOfKindHint 4 hand] :: [[[Card]]]
④ concat $ 
  catMaybes [nOfKindHint 2 hand, nOfKindHint 3 hand, nOfKindHint 4 hand] :: [[Card]]
⑤ concat . concat $ 
  catMaybes [nOfKindHint 2 hand, nOfKindHint 3 hand, nOfKindHint 4 hand] :: [Card]

というわけで、allNOfKinds関数は次のような実装になりました。

allNOfKinds :: Hand -> [Card]
allNOfKinds hand = concat . concat 
  $ catMaybes [nOfKindHint 2 hand, nOfKindHint 3 hand, nOfKindHint 4 hand]

で、実際には手札からこのallNOfKinds関数を除外したものを捨て札としたいわけですから、 次のようなnOfKindDiscards関数を実装すればOKですね。

nOfKindDiscards :: Hand -> DiscardList
nOfKindDiscards hand = filter (flip notElem $ allNOfKinds hand) $ fromHand hand
  where
    allNOfKinds :: Hand -> [Card]
    allNOfKinds hand = concat . concat 
      $ catMaybes [nOfKindHint 2 hand, nOfKindHint 3 hand, nOfKindHint 4 hand]

filter関数周りは、前回drawHand関数を作った際にやった事と同じです。

纏め上げる

役が確定しているパターンはこの他に、ストレートの場合とフラッシュの場合が考えられます。

やはり第三回で実装した、以下2つの関数が役に立ちそうです。

straightHint :: Hand -> Maybe Card
flushHint :: Hand -> Maybe Card

返却値は、それぞれ最強カードを返すのでした。 しかし今回はこの返却値は使わずに、結果がJustだった場合は全てのカードを残すようにしたい・・・ つまり捨て札が0枚になるようにしたいわけですね。

このような場合、Control.Applicativeモジュールで定義されている(*>)演算子が役に立ちます。 (Applicativeについては前回やりましたね、Maybe型はApplicative型クラスのインスタンスなのでした。)

*Main> :t (*>)
(*>) :: Applicative f => f a -> f b -> f b
*Main> Just 5 *> Just "Hoge"
Just "Hoge"
*Main> Just True *> Just "Hoge"
Just "Hoge"
*Main> Nothing *> Just "Hoge"
Nothing
*Main> Just 5 *> Nothing
Nothing

フラッシュかストレート、「どちらか」がJustだった場合には捨て札無しという事で空リストを返したいわけですが、 このようなパターンは以前にも一度あったのを覚えてますか?

そうです、「どちらかがJustの場合にのみ結果を返す」にはmplus関数でしたね。 というわけで、これらを使って捨て札の判定処理、aiSelectDiscards関数を完成させてしまいましょう。

aiSelectDiscards :: Hand -> DiscardList
aiSelectDiscards hand = 
  case straightHint hand `mplus` flushHint hand *> Just [] of 
    Nothing -> nOfKindDiscards hand
    Just xs -> xs 

mplusの結果がNothingだった場合に、nOfKindDiscardsの結果を返すようにしました。
nOfKindDiscardsはブタだった場合、手札をまるっと捨て札として返してきますので、 得に役無しの場合については考える必要ありません。

勝敗判定処理

さて、勝敗判定処理は簡単なのでちゃちゃーっと説明しちゃいますね。

judgeVictory :: (PokerHand, Card) -> (PokerHand, Card) -> Ordering
judgeVictory l r = compare (pullStrength l) (pullStrength r)
  where
    pullStrength :: (PokerHand, Card) -> (PokerHand, Int)
    pullStrength = fmap cardStrength

単純な大小比較によって勝敗を判定できるように、PokerHand型をOrd型クラスのインスタンスにしておいたのが役に立ちます。

Card型もOrd型クラスのインスタンスなので、 pokerHand関数の結果をそのまんまcompare関数で比較する事もできるのですが、 今回開発しているポーカーのルールでは、スートによって強弱に差は無いものとしたいです。

例えば、ハートの10とスペードの10は同じ強さになります。 そこで、予めCardsモジュールで定義しておいてcardStrength関数を使って、 カードの強さを表す数値に置き換えて、それから比較するという手順を踏んだわけですね。

pullStrength関数の実装について「おや?」となった方もいるかもしれません。 二値のタプルはFunctorとなっており、fmapを使って2要素目に関数を適用する事ができるのです。

ghci> fmap (\*2) (0, 100)
(0,200)
ghci> fmap show (0, 100)
(0,"100")

プロトタイプを改造

さて、今回作成した捨て札選択処理と勝敗判定処理を組み合わせて、 以前作ったプロトタイプを、CPU対戦できるように拡張します。

例によって、プロトタイプはべたべたと手続きプログラミングしているだけなので、得に面白い所はありません。

純粋関数型である事が災いして、山札の管理がかなり煩雑になってしまっていますが、 この問題については、プロトタイプではないゲーム本体を開発していく際にはちゃんと解決していきます。 (先に答えを言ってしまうと、山札を状態管理していない事が原因なので、山札を状態で管理してしまえば良いのです。)

main :: IO ()
main  = do
  putStrLn "------------------"
  putStrLn "-- simple poker --"
  putStrLn "------------------"
  deck <- shuffleM allCards
  case getHand deck of
    Nothing -> error "予期せぬエラー : getHand in simpleGame"
    Just res -> matchPoker res
  ynQuestion "-- もっかいやる?" main (putStrLn "-- またねノシノシ")

data Player = Player | Enemy deriving Eq

showPlayerName :: Player -> String
showPlayerName Player = "あなた"
showPlayerName Enemy = "あいて"

matchPoker :: (Hand, Deck) -> IO ()
matchPoker (mhand, deck) = do
  (mres, ndeck, nmhand) <- playPoker mhand deck Player
  case getHand ndeck of
    Nothing -> error "予期せぬエラー : getHand in matchPoker"
    Just (ehand, odeck) -> do
      (eres, _, nehand) <- playPoker ehand odeck Enemy
      printResult nmhand nehand mres eres
  
playPoker :: Hand -> Deck -> Player -> IO ((PokerHand, Card), Deck, Hand)
playPoker hand deck player = do
  discards <- if player == Player 
    then inputDisuse hand
    else aiDisuse hand
  case drawHand deck discards hand of
    Nothing -> error "予期せぬエラー : drawHand"
    Just (nhand, ndeck) -> do
      let res = pokerHand nhand
      return (res, ndeck, nhand)

inputDisuse :: Hand -> IO DiscardList
inputDisuse hand = do
  printHand [] hand Player
  putStrLn "-- 捨てるカードを選んでね"
  gotDisuse <- getDiscardList hand
  case gotDisuse of
    Nothing -> do
      putStrLn "-- 1~5の数値を並べて入力してね"
      inputDisuse hand
    Just disuses -> do
      printHand disuses hand Player
      ynQuestion "-- あなた:これでいい?" (return disuses) (inputDisuse hand)

aiDisuse :: Hand -> IO DiscardList
aiDisuse hand = do
  let res = aiSelectDiscards hand
  printHand res hand Enemy
  putStrLn "-- あいて:これでいいよ!" 
  return res

----
          
printResult :: Hand -> Hand -> (PokerHand, Card) -> (PokerHand, Card) -> IO ()
printResult mhand ehand mres@(mph, mcard) eres@(eph, ecard) = do
  putStrLn " ***** 結果発表!! *****"
  printHand [] mhand Player
  printHand [] ehand Enemy
  putStrLn $ concat ["あなたの手札は ", show mph, " で、最強カードは ", show mcard, " でした"]
  putStrLn $ concat ["あいての手札は ", show eph, " で、最強カードは ", show ecard, " でした"]
  case judgeVictory mres eres of
    LT -> putStrLn "あなたの負けです"
    EQ -> putStrLn "引き分けです"
    GT -> putStrLn "あなたの勝ちです"

printHand :: DiscardList -> Hand -> Player -> IO ()
printHand dis hand player = 
  putStrLn $ "-- " ++ showPlayerName player ++ "の手札 : " ++ showChangeHand dis hand

ynQuestion :: String -> IO a -> IO a -> IO a
ynQuestion s yes no = do
  putStrLn $ s ++ "(y/n)"
  input <- getLine
  case input of 
    "y" -> yes
    "n" -> no
    _ -> do
      putStrLn "-- `y`か`n`で入力してね"
      ynQuestion s yes no

showChangeHand :: DiscardList -> Hand -> String
showChangeHand dis h = let
  judge x = if elem x dis then " " ++ show x ++ " " else "[" ++ show x ++ "]"
  in concat $ map judge (fromHand h)

長ったらしくて面白くもないやつなので、説明とかしないです。
前回とのdiffは、以下のURLを確認してください。

https://gist.github.com/tokiwoousaka/b471aa0efed725c6a05d/revisions

とにかく、これでCPUと対戦出来るプロトタイプが出来ました。

------------------
-- simple poker --
------------------
-- あなたの手札 : [H6_][D7_][C8_][S8_][CQ_]
-- 捨てるカードを選んでね
125
-- あなたの手札 :  H6_  D7_ [C8_][S8_] CQ_ 
-- あなた:これでいい?(y/n)
y
-- あいての手札 : [C3_][S3_] S7_  SJ_  SK_ 
-- あいて:これでいいよ!
 ***** 結果発表!! *****
-- あなたの手札 : [C7_][D8_][C8_][S8_][D10]
-- あいての手札 : [C3_][S3_][H8_][DQ_][DK_]
あなたの手札は ThreeOfAKind で、最強カードは S8_ でした
あいての手札は OnePair で、最強カードは S3_ でした
あなたの勝ちです
-- もっかいやる?(y/n)
y
------------------
-- simple poker --
------------------
-- あなたの手札 : [D2_][H3_][D5_][H7_][S9_]
-- 捨てるカードを選んでね
12345
-- あなたの手札 :  D2_  H3_  D5_  H7_  S9_ 
-- あなた:これでいい?(y/n)
y
-- あいての手札 :  H4_  D6_ [HJ_][SJ_] CK_ 
-- あいて:これでいいよ!
 ***** 結果発表!! *****
-- あなたの手札 : [S2_][S6_][C8_][HQ_][DQ_]
-- あいての手札 : [S3_][C10][S10][HJ_][SJ_]
あなたの手札は OnePair で、最強カードは DQ_ でした
あいての手札は TwoPair で、最強カードは SJ_ でした
あなたの負けです
-- もっかいやる?(y/n)
n
-- またねノシノシ

わりとたのしい。

まとめ

というわけで、今回はべたべたとCPUと対戦する機能を作ってみました。

多少新しい事も紹介しましたが、 基本的には今まで作った関数を、これまで使ったテクニックを組み合わせる事によって実現していく感じになりましたね。

Haskellで実装した関数の使い回しのしやすさを体感できたのではないでしょうか。

さて、そろそろMain.hsも煩雑になってきた頃ですし、宣言通り次回はこれまで作った関数群を整理していきます。 プログラム全体も良い規模になってきましたので、プロジェクトとしてちゃんと構成しなおし、 cabalstack等といったビルドツールの使い方も簡単にご紹介しようと思います。

それではノシノシ

←前 次→