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
も煩雑になってきた頃ですし、宣言通り次回はこれまで作った関数群を整理していきます。
プログラム全体も良い規模になってきましたので、プロジェクトとしてちゃんと構成しなおし、
cabal
やstack
等といったビルドツールの使い方も簡単にご紹介しようと思います。
それではノシノシ