はじめに
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
カードの定義
カードはsuit
とrank
の組み合わせとして定義する。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)
ranks
、suits
の初期化。スートごとにまとめて処理することで、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()