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

# はじめに

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

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

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

https://qiita.com/JuneOgust/items/94163c56f159fc00f917

# 役の定義

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

```python
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
```

# カードの定義

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

```python
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`を求める。ジョーカーは、すべてその数字を増やす方向で使う。

```python
    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枚組以上ならファイブ・オブ・ア・カインド。

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

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

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

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

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

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

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

`next_pair`の定義は以下。

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

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

```python
    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)
```

### ストレートの判定

ストレートの判定。

```python
    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扱いにしてストレートにできる。

```python
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`と同じフォーマット)。

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

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

```python
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=[])
```

### フラッシュの判定

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

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

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

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

### 初期化

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

```python
from collections import defaultdict

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

`ranks`、`suits`の初期化。スートごとにまとめて処理することで、`ranks`内のスートの順番を固定している。
```python
    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)
```

# ソースコード

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

```python
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()
```