0
0

Pythonで汎用のポーカー役判定

Last updated at Posted at 2024-05-18

はじめに

Python で、汎用のポーカー役判定関数を作る。

  • 手札は何枚でもOK。5枚以上なら5枚を選んで役を作る。ただし、4枚以下のストレート・フラッシュの判定は行わない
  • ジョーカーは何枚入っていてもOK
  • スート・数字ともに同じカードが複数あってもOK

こちらのページの「手札が5枚でなくても役の判定が行えるようにしたかった」との記述に触発されて開発した。

役の定義

役を列挙型で定義する。ロイヤルフラッシュは、後述する仕様により、後からでもストレートフラッシュと区別できるので、独立した役としては扱わない。

from enum import IntEnum

class Cat(IntEnum):
    FIVE_OF_A_KIND  = 9
    STRAIGHT_FLUSH  = 8
    FOUR_OF_A_KIND  = 7
    FULL_HOUSE      = 6
    FLUSH           = 5
    STRAIGHT        = 4
    THREE_OF_A_KIND = 3
    TWO_PAIR        = 2
    ONE_PAIR        = 1
    HIGH_CARD       = 0

カードの定義

カードはsuitrankの組み合わせとして定義する。suitはトランプのマークの文字列をそのまま使う。rankは番号だが、A については1ではなく14として扱う(役判定の簡略化のため)。使用例については、末尾のソースコードのテストケースを参照。

from functools import total_ordering

@total_ordering
class Card:
    RANK2STR = ['', '', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
    STR2RANK = {s: r for r, s in enumerate(RANK2STR)}
    def __init__(self, suit='JA', rank=None):
        if rank is None:
            self.suit = suit[0]
            self.rank = self.STR2RANK[suit[1:]]
        else:
            self.suit = suit
            self.rank = rank
    def is_joker(self):
        return self.suit == 'J'
    def __repr__(self):
        return f"Card('{self.suit}{self.RANK2STR[self.rank]}')"
    def __eq__(self, other):
        return self.rank == other.rank
    def __lt__(self, other):
        return self.rank < other.rank

ジョーカーはsuit'J'にするルールとする。比較関数については、rankのみで判定するようにする(ポーカーだとsuitは強さに無関係なので)。

役判定

役判定関数は、カードのリストを受け取って、2要素のタプルを返す仕様とする。先頭は役の列挙型で、2番目は役が分かりやすいように並べ替えたカード。ジョーカーについては、どの数字として扱うかも設定する。こうしておけば、この関数の戻り値を単純に比較するだけで、役の強弱を判定できる。使用例については、末尾のソースコードのテストケースを参照。

以下で、役の種類ごとに判定方法を説明する。

同じ数字のカードの枚数による判定

以下の役は、同様の方法で判定できる。

  • ファイブ・オブ・ア・カインド
  • フォー・オブ・ア・カインド
  • フルハウス
  • スリー・オブ・ア・カインド
  • ツーペア
  • ワンペア

基本的には、一番枚数の多い数字によって役が決まる(4枚組があればフォー・オブ・ア・カインドなど)。ただし、同じ役の場合は数字の大小で強さが決まるので、枚数が同じなら数字の大きいものを採用する。

これだけだと、フルハウスとスリー・オブ・ア・カインドの区別がつかないので、2番目に枚数の多い数字も参照する必要がある。次に多いのが2枚組ならフルハウス、1枚ならスリー・オブ・ア・カインドとなる。

さらに、「5枚を選ぶ」という仕様から、以下の件も考慮が必要となる。

  • 同じ数字が6枚以上あっても、ファイブ・オブ・ア・カインドまでしか作成できない。そのため、例えば2の6枚組とAの5枚組があった場合は、Aのファイブ・オブ・ア・カインドを採用しないといけない。よって、数字の大小を比較する際には、5枚組以上の数字を同等に扱う必要がある
  • 同様に、2番目に枚数の多い数字についても、2枚組以上の数字を同等に扱う必要がある

最後に、ジョーカーの扱いだが、こちらは一番枚数の多い数字を増やす方向で使えばよい。2番目に枚数の多い数字を増やしてフルハウスやツーペアを狙うより、一番枚数の多い数字を増やしてフォー・オブ・ア・カインドやスリー・オブ・ア・カインドを目指す方が強くなる。ただし、ジョーカーが5枚以上ある場合は、すべてAを増やす方向で使う(Aのファイブ・オブ・ア・カインド狙い)。

実際のコードは、以下のようになっている。ここで、jokerはジョーカーの枚数、ranksはカードを数字ごとに分けて保存した辞書。

まずは、一番枚数の多い数字mを求める。ジョーカーは、すべてその数字を増やす方向で使う。

    m = max(ranks, key=lambda r: (min(5, len(ranks[r]) + joker), r)) if joker < 5 and ranks else 14
    ranks[m] += [Card('J', m)] * joker

5枚組以上ならファイブ・オブ・ア・カインド。

    if len(ranks[m]) >= 5:
        return Cat.FIVE_OF_A_KIND, ranks[m][:5]

4枚組ならフォー・オブ・ア・カインド。残りの一枚は、数字が一番大きいものを採用する。ranks.pop(m)によりranksからmを取り除いていることに注意。

    if len(ranks[m]) == 4:
        return Cat.FOUR_OF_A_KIND, ranks.pop(m) + collect_nlargest(1, ranks)

collect_nlargestは、ranksから数字の大きいもの上位n枚を取り出す関数。

def collect_nlargest(n, ranks):
    return [c for r in sorted(ranks, reverse=True) for c in ranks[r]][:n]

3枚組で、2番目に枚数の多い数字が2枚組以上なら、フルハウス。

    if len(ranks[m]) == 3 and (n := next_pair(ranks, m)):
        return Cat.FULL_HOUSE, ranks[m] + ranks[n][:2]

next_pairの定義は以下。

def next_pair(ranks, m):
    return max((r for r in ranks if len(ranks[r]) > 1 and r != m), default=0)

残りの判定は、ここまでの説明で理解できるはずなので省略。

    if len(ranks[m]) == 3:
        return Cat.THREE_OF_A_KIND, ranks.pop(m) + collect_nlargest(2, ranks)
    if len(ranks[m]) == 2 and (n := next_pair(ranks, m)):
        return Cat.TWO_PAIR, ranks.pop(m) + ranks.pop(n) + collect_nlargest(1, ranks)
    if len(ranks[m]) == 2:
        return Cat.ONE_PAIR, ranks.pop(m) + collect_nlargest(3, ranks)
    return Cat.HIGH_CARD, collect_nlargest(5, ranks)

ストレートの判定

ストレートの判定。

    if ret := collect_straight(ranks, joker):
        return Cat.STRAIGHT, ret

collect_straightの定義。数字の重複を除いたうえでソートして、連続する5枚の最大と最小の差が4ならストレートと判定する。例えば、10,9,8,7,6の並びなら、10-6=4なのでストレート。

5,4,3,2,Aを処理するため、Aのカードについては、数字1のカードとしても登録しておく。

ジョーカーについては、連続する枚数としてジョーカーを除いた枚数を使ったうえで、最大と最小の差が5未満で判定すればよい。例えば、ジョーカー1枚で、10,9,8,6の並びなら、10-6=4でジョーカーを7扱いにしてストレートにできる。また、9,8,7,6の並びなら、9-6=3でジョーカーを10扱いにしてストレートにできる。

def collect_straight(ranks, joker):
    if 14 in ranks:
        ranks = ranks.copy()
        ranks[1] = ranks[14]
    d, keys = max(0, 4 - joker), sorted(ranks, reverse=True)
    for i in range(len(keys) - d):
        if keys[i] - keys[i + d] < 5:
            v = min(14, keys[i + d] + 4)
            return [ranks[r][0] if r in ranks else Card('J', r) for r in range(v, v - 5, -1)]
    return []

なお、「同じ数字のカードの枚数による判定」にてranksにジョーカーを追加しているが、以下の理由によりストレートの判定には影響はない。

  • ストレートでは各数字の先頭のカードしか見ない(ジョーカーは末尾に追加)
  • ジョーカーが5枚以上の場合、先頭にジョーカーが来るかもしれないが、必ずファイブ・オブ・ア・カインドになるのでストレートの判定は行われない
  • 手札が4枚以下ですべてジョーカーの場合、先頭にジョーカーが来るかもしれないが、4枚以下のストレートの判定は行わない仕様

ストレートフラッシュの判定

ストレートフラッシュの判定。「5枚を選ぶ」仕様により、「すべて同じスート」という判定を使うことができない。仕方ないので、スートごとに個別に判定して、最後に一番強いものを選択する。

ここで、suitsはカードをスートごとに分けて保存した辞書(値はranksと同じフォーマット)。

    if ret := max_hand(collect_straight(r, joker) for r in suits.values()):
        return Cat.STRAIGHT_FLUSH, ret

max_handは、数字が大きいものを優先して選択する。数字が同じ場合は、ジョーカーの使用枚数が少ないもの、ジョーカー以外のカードの数字が大きくなるものを優先して選択する。

def max_hand(hands):
    return max(hands, key=lambda h: (h, -sum(c.is_joker() for c in h), [not c.is_joker() for c in h]), default=[])

フラッシュの判定

フラッシュの判定。基本的にはストレートフラッシュと同様。

    if ret := max_hand(collect_flush(r, joker) for r in suits.values()):
        return Cat.FLUSH, ret

collect_flushは、ジョーカーを全てA扱いにしたうえで、数字の大きいもの上位5枚を取り出し、5枚そろったら返す関数。

def collect_flush(ranks, joker):
    ranks[14] += [Card()] * joker
    ret = collect_nlargest(5, ranks)
    return ret if len(ret) >= 5 else []

初期化

上で出てきたjokerの初期化。まずはジョーカー以外のカードをnormalに集めている。

from collections import defaultdict

def poker_rank(hand):
    normal = [c for c in hand if not c.is_joker()]
    joker = len(hand) - len(normal)

rankssuitsの初期化。スートごとにまとめて処理することで、ranks内のスートの順番を固定している。

    ranks = defaultdict(list)
    suits = defaultdict(lambda: defaultdict(list))
    for suit in '♠♥♦♣':
        for c in normal:
            if c.suit == suit:
                ranks[c.rank].append(c)
                suits[suit][c.rank].append(c)

ソースコード

全体のソースコードを以下に載せる。単体テストも含む。

from enum import IntEnum
from functools import total_ordering
from collections import defaultdict
import unittest
from itertools import permutations

class Cat(IntEnum):
    FIVE_OF_A_KIND  = 9
    STRAIGHT_FLUSH  = 8
    FOUR_OF_A_KIND  = 7
    FULL_HOUSE      = 6
    FLUSH           = 5
    STRAIGHT        = 4
    THREE_OF_A_KIND = 3
    TWO_PAIR        = 2
    ONE_PAIR        = 1
    HIGH_CARD       = 0

@total_ordering
class Card:
    RANK2STR = ['', '', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
    STR2RANK = {s: r for r, s in enumerate(RANK2STR)}
    def __init__(self, suit='JA', rank=None):
        if rank is None:
            self.suit = suit[0]
            self.rank = self.STR2RANK[suit[1:]]
        else:
            self.suit = suit
            self.rank = rank
    def is_joker(self):
        return self.suit == 'J'
    def __repr__(self):
        return f"Card('{self.suit}{self.RANK2STR[self.rank]}')"
    def __eq__(self, other):
        return self.rank == other.rank
    def __lt__(self, other):
        return self.rank < other.rank

def poker_rank(hand):
    normal = [c for c in hand if not c.is_joker()]
    joker = len(hand) - len(normal)
    ranks = defaultdict(list)
    suits = defaultdict(lambda: defaultdict(list))
    for suit in '♠♥♦♣':
        for c in normal:
            if c.suit == suit:
                ranks[c.rank].append(c)
                suits[suit][c.rank].append(c)
    m = max(ranks, key=lambda r: (min(5, len(ranks[r]) + joker), r)) if joker < 5 and ranks else 14
    ranks[m] += [Card('J', m)] * joker
    if len(ranks[m]) >= 5:
        return Cat.FIVE_OF_A_KIND, ranks[m][:5]
    if ret := max_hand(collect_straight(r, joker) for r in suits.values()):
        return Cat.STRAIGHT_FLUSH, ret
    if len(ranks[m]) == 4:
        return Cat.FOUR_OF_A_KIND, ranks.pop(m) + collect_nlargest(1, ranks)
    if len(ranks[m]) == 3 and (n := next_pair(ranks, m)):
        return Cat.FULL_HOUSE, ranks[m] + ranks[n][:2]
    if ret := max_hand(collect_flush(r, joker) for r in suits.values()):
        return Cat.FLUSH, ret
    if ret := collect_straight(ranks, joker):
        return Cat.STRAIGHT, ret
    if len(ranks[m]) == 3:
        return Cat.THREE_OF_A_KIND, ranks.pop(m) + collect_nlargest(2, ranks)
    if len(ranks[m]) == 2 and (n := next_pair(ranks, m)):
        return Cat.TWO_PAIR, ranks.pop(m) + ranks.pop(n) + collect_nlargest(1, ranks)
    if len(ranks[m]) == 2:
        return Cat.ONE_PAIR, ranks.pop(m) + collect_nlargest(3, ranks)
    return Cat.HIGH_CARD, collect_nlargest(5, ranks)

def collect_straight(ranks, joker):
    if 14 in ranks:
        ranks = ranks.copy()
        ranks[1] = ranks[14]
    d, keys = max(0, 4 - joker), sorted(ranks, reverse=True)
    for i in range(len(keys) - d):
        if keys[i] - keys[i + d] < 5:
            v = min(14, keys[i + d] + 4)
            return [ranks[r][0] if r in ranks else Card('J', r) for r in range(v, v - 5, -1)]
    return []

def collect_flush(ranks, joker):
    ranks[14] += [Card()] * joker
    ret = collect_nlargest(5, ranks)
    return ret if len(ret) >= 5 else []

def max_hand(hands):
    return max(hands, key=lambda h: (h, -sum(c.is_joker() for c in h), [not c.is_joker() for c in h]), default=[])

def collect_nlargest(n, ranks):
    return [c for r in sorted(ranks, reverse=True) for c in ranks[r]][:n]

def next_pair(ranks, m):
    return max((r for r in ranks if len(ranks[r]) > 1 and r != m), default=0)

class Test(unittest.TestCase):
    def assertPokerRank(self, arg, expected):
        for perm in permutations(arg):
            cat, cards = poker_rank(perm)
            if cat != expected or any(a != b or a.suit != b.suit for a, b in zip(cards, arg)):
                self.fail(f'poker_rank({perm}): {(cat,cards)!r} != {(expected,arg[:5])!r}')
    def test(self):
        self.assertPokerRank([Card('♠2'),Card('♥2'),Card('♦2'),Card('♣2'),Card('J2'),Card('♣J'),Card('♥9')],Cat.FIVE_OF_A_KIND)
        self.assertPokerRank([Card('♠A'),Card('♣A'),Card(),Card(),Card(),Card(),Card()],Cat.FIVE_OF_A_KIND)
        self.assertPokerRank([Card('♠6'),Card('J6'),Card('J6'),Card('J6'),Card('J6'),Card('♠4'),Card('♥3')],Cat.FIVE_OF_A_KIND)
        self.assertPokerRank([Card('♠A'),Card(),Card(),Card(),Card(),Card('♥2'),Card('♦2')],Cat.FIVE_OF_A_KIND)
        self.assertPokerRank([Card(),Card(),Card(),Card(),Card(),Card(),Card()],Cat.FIVE_OF_A_KIND)

        self.assertPokerRank([Card('♠A'),Card('♠K'),Card('♠Q'),Card('♠J'),Card('♠10'),Card('♠2'),Card('♠9')],Cat.STRAIGHT_FLUSH)
        self.assertPokerRank([Card('♣A'),Card('♣K'),Card('JQ'),Card('JJ'),Card('J10'),Card('♥Q'),Card('♥J')],Cat.STRAIGHT_FLUSH)
        self.assertPokerRank([Card('J6'),Card('♣5'),Card('♣4'),Card('♣3'),Card('♣2'),Card('♣A'),Card('♣K')],Cat.STRAIGHT_FLUSH)
        self.assertPokerRank([Card('♦5'),Card('♦4'),Card('♦3'),Card('♦2'),Card('♦A'),Card('♣A'),Card('♣K')],Cat.STRAIGHT_FLUSH)
        self.assertPokerRank([Card('J5'),Card('♠4'),Card('♠3'),Card('♠2'),Card('♠A'),Card('♣A'),Card('♣K')],Cat.STRAIGHT_FLUSH)

        self.assertPokerRank([Card('♠2'),Card('♥2'),Card('♦2'),Card('♣2'),Card('♦A'),Card('♠K'),Card('♠Q')],Cat.FOUR_OF_A_KIND)
        self.assertPokerRank([Card('♠Q'),Card('♦Q'),Card('♣Q'),Card('JQ'),Card('♣4'),Card('♣3'),Card('♣2')],Cat.FOUR_OF_A_KIND)
        self.assertPokerRank([Card('♦5'),Card('♣5'),Card('J5'),Card('J5'),Card('♣J'),Card('♥9'),Card('♠8')],Cat.FOUR_OF_A_KIND)
        self.assertPokerRank([Card('♦9'),Card('J9'),Card('J9'),Card('J9'),Card('♣7'),Card('♠5'),Card('♥4')],Cat.FOUR_OF_A_KIND)

        self.assertPokerRank([Card('♠A'),Card('♦A'),Card('♣A'),Card('♥6'),Card('♦6'),Card('♣6'),Card('♣K')],Cat.FULL_HOUSE)
        self.assertPokerRank([Card('♠10'),Card('♦10'),Card('J10'),Card('♠7'),Card('♣7'),Card('♦2'),Card('♣2')],Cat.FULL_HOUSE)

        self.assertPokerRank([Card('♥A'),Card('♥K'),Card('♥Q'),Card('♥J'),Card('♥4'),Card('♥3'),Card('♥2')],Cat.FLUSH)
        self.assertPokerRank([Card('♣J'),Card('♣9'),Card('♣8'),Card('♣7'),Card('♣6'),Card('♣4'),Card('♣3')],Cat.FLUSH)
        self.assertPokerRank([Card(),Card('♦9'),Card('♦8'),Card('♦6'),Card('♦4'),Card('♦3'),Card('♠A')],Cat.FLUSH)

        self.assertPokerRank([Card('♠A'),Card('♥K'),Card('♥Q'),Card('♥J'),Card('♥10'),Card('♠2'),Card('♠9')],Cat.STRAIGHT)
        self.assertPokerRank([Card('♥A'),Card('♣K'),Card('♦Q'),Card('JJ'),Card('J10'),Card('♦9'),Card('♠8')],Cat.STRAIGHT)
        self.assertPokerRank([Card('J6'),Card('♦5'),Card('♠4'),Card('♥3'),Card('♣2'),Card('♦9'),Card('♠8')],Cat.STRAIGHT)
        self.assertPokerRank([Card('♠5'),Card('♥4'),Card('♣3'),Card('♦2'),Card('♠A'),Card('♣8'),Card('♣7')],Cat.STRAIGHT)
        self.assertPokerRank([Card('J5'),Card('♣4'),Card('♦3'),Card('♠2'),Card('♥A'),Card('♣8'),Card('♣7')],Cat.STRAIGHT)

        self.assertPokerRank([Card('♠2'),Card('♦2'),Card('♣2'),Card('♦J'),Card('♣6'),Card('♠5'),Card('♥4')],Cat.THREE_OF_A_KIND)
        self.assertPokerRank([Card('♠8'),Card('♣8'),Card('J8'),Card('♠Q'),Card('♦9'),Card('♦3'),Card('♥6')],Cat.THREE_OF_A_KIND)
        self.assertPokerRank([Card('♠K'),Card('JK'),Card('JK'),Card('♥8'),Card('♦7'),Card('♠3'),Card('♣2')],Cat.THREE_OF_A_KIND)

        self.assertPokerRank([Card('♥J'),Card('♣J'),Card('♠8'),Card('♦8'),Card('♠4'),Card('♦4'),Card('♣2')],Cat.TWO_PAIR)

        self.assertPokerRank([Card('♠7'),Card('♣7'),Card('♥A'),Card('♠J'),Card('♦4'),Card('♥3'),Card('♣2')],Cat.ONE_PAIR)
        self.assertPokerRank([Card('♦Q'),Card('JQ'),Card('♦9'),Card('♠8'),Card('♦5'),Card('♥4'),Card('♣2')],Cat.ONE_PAIR)

        self.assertPokerRank([Card('♣A'),Card('♦K'),Card('♠Q'),Card('♥J'),Card('♠4'),Card('♥3'),Card('♠2')],Cat.HIGH_CARD)
        self.assertPokerRank([Card('♣J'),Card('♥9'),Card('♠8'),Card('♦7'),Card('♣6'),Card('♥4'),Card('♣2')],Cat.HIGH_CARD)

if __name__ == '__main__':
    unittest.main()
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0