「大石泉すきポーカー」の手役の強さ判定プログラムをPythonで書きました

# 概要

- よ一ど ♀([@yo_do_53](https://twitter.com/yo_do_53))さんが、[診断メーカー](https://shindanmaker.com/)で「[大石泉すき](https://shindanmaker.com/733464)」診断を作る
  - 「大石泉すき」の5種類の文字から重複順列がランダムに取り出される
  - 「大石泉すき」と並ぶ確率は`1 / (5 ** 5) = 1 / 3125`
- 桜花([@ikmesyk3](https://twitter.com/ikmesyk3))さんが、↑の並びをポーカーに見立てた「[大石泉すきポーカー](https://twitter.com/ikmesyk3/status/1043484395653328896)」を提案
- 私が、[役判定プログラムを書く](https://qiita.com/YSRKEN/items/262040d07e7e343bc70e)
- 私が、手役の強さ判定プログラムを書く　←New！

# 実装の過程

　今回もPythonを使用します。Pythonでは[ソート時に比較関数を使う](https://qiita.com/norioc/items/cb533d009aa63453df40)ことができるので、次のように仕様を設定します。

|compare_hand(a, b)|判定結果|
|----|----|----|
|-1|aの方が強い手役|
|0|優劣が存在しない|
|1|bの方が強い手役|

　例えば、`compare_hand('大石泉きす', '大石大石泉')`の場合は-1を返すわけですね。
　言うまでもありませんが、 **役の強さが異なる場合はそこで決着** しますので、それ以外のケースを考えることになります。

```python
def judge_hand(name: str) -> int:
    """役を判定する

    Parameters
    ----------
    name : str
        「大石泉すき」から重複を許して5文字取って並べたもの

    Returns
    -------
    int
        判定された役(0がロイヤルストレートフラッシュ、ブタが8)
    """

    # 各文字をバラす
    name_char = list(name)

    # 各文字の出現回数を数え、出現回数部分だけ抽出したもの。
    # 例：「大大すすき」→[2, 2, 1]
    char_count = collections.Counter(name_char).values()

    # 手役の判定処理
    if name == '大石泉すき':
        # 説明不要
        return 0
    elif len(set(name_char)) == 1:
        # uniq処理を施して1つしか残らない＝ファイブカード
        return 1
    elif 4 in char_count:
        # ある文字が4つある＝フォーカード
        return 2
    elif len(char_count) == 5:
        # 5種類ある＝元の文字列が5文字なので各文字ある＝ストレートフラッシュ
        return 3
    elif 2 in char_count and 3 in char_count:
        # 出現回数に「2」と「3」がある＝フルハウス
        return 4
    elif 'すき' in name:
        # すき
        return 5
    elif 3 in char_count:
        # 出現回数に「3」がある＝スリーカード
        return 6
    elif 2 in char_count and len(char_count) == 3:
        # 出現回数が3文字分あって「2」が含まれる＝消去法からツーペア
        return 7
    return 8


def compare_hand(a: str, b: str) -> int:
    """手役を比較する

    Parameters
    ----------
    a : str
        「大石泉すき」から重複を許して5文字取って並べたもの1
    b : str
        「大石泉すき」から重複を許して5文字取って並べたもの2

    Returns
    -------
    int
        aが強ければ-1、同じなら0、bが強ければ1
    """

    # 役が異なれば当然そこで判定できる
    hand1 = judge_hand(a)
    hand2 = judge_hand(b)
    if hand1 != hand2:
        if hand1 < hand2:
            return -1
        if hand1 > hand2:
            return 1

    # 「同じ強さの場合「すき」が(より後ろで)揃っているものが勝つ。」

    # 「同じ強さですきの位置も同じの場合正位置に近い文字数が多いものが勝つ。」

    # 「上記2つのいずれも同数の場合、正位置にある文字の画数が多いものが勝つ。」

    return 0
```

### ルール1「同じ強さの場合「すき」が(より後ろで)揃っているものが勝つ。」

　これを、次のように翻訳します。

- 「すき」の文字列がそれぞれの手役に存在するかを確認する
- 片方しか存在しない場合、存在している方が無条件で勝つ
- 両方存在する場合、その位置がより後ろな方が勝つ

一見複雑そうですが、`str#find`メソッドが「引数に指定した文字列の位置を左端0スタートで返す。存在しない場合は-1を返す」仕様なので、「**findメソッドの戻り値が異なる場合、大きい方が勝つ**」と言い換えられます。

```python
    # 「同じ強さの場合「すき」が(より後ろで)揃っているものが勝つ。」
    pos1 = a.find('すき')
    pos2 = b.find('すき')
    if pos1 != pos2:
        if pos1 < pos2:
            return 1
        if pos1 > pos2:
            return -1
```

### ルール2「同じ強さですきの位置も同じの場合正位置に近い文字数が多いものが勝つ。」

　これを、最初は次のように翻訳しました。

- 手役の各文字について、「大」「石」「泉」「す」「き」とどれだけ離れているか[SSD](https://algorithm.joho.info/image-processing/template-matching-sad-ssd-ncc/)を計算する
- SSDが異なる場合、小さい方が勝つ

　しかし、これだと`compare_hand('すき泉きす', 'すき石石き')`の値が「この段階で」-1になってしまいます。テストを通すには、

- 手役の各文字について、「大」「石」「泉」「す」「き」の本来の位置と異なっている場合は0点、同じ場合は1点とする
- 合計点数が大きい方が勝つ

と翻訳する必要がありました。

```python
def calc_pos_score(x: str) -> int:
    p1: int = '大石泉すき'.find(x[0:1])
    p2: int = max('石泉すき'.find(x[1:2]), '石大'.find(x[1:2]))
    p3: int = max('泉すき'.find(x[2:3]), '泉石大'.find(x[2:3]))
    p4: int = max('すき'.find(x[3:4]), 'す泉石大'.find(x[3:4]))
    p5: int = 'きす泉石大'.find(x[4:5])

    sum = 0
    sum += 1 if p1 == 0 else 0
    sum += 1 if p2 == 0 else 0
    sum += 1 if p3 == 0 else 0
    sum += 1 if p4 == 0 else 0
    sum += 1 if p5 == 0 else 0

    return sum

    # 「同じ強さですきの位置も同じの場合正位置に近い文字数が多いものが勝つ。」
    ssd1 = calc_pos_score(a)
    ssd2 = calc_pos_score(b)
    if ssd1 != ssd2:
        if ssd1 < ssd2:
            return 1
        if ssd1 > ssd2:
            return -1
```

### ルール3「上記2つのいずれも同数の場合、正位置にある文字の画数が多いものが勝つ。」

画数を[漢字辞典オンライン](https://kanji.jitenon.jp/cat/kakusu.html)と[KKJN.JP](https://www.kkjn.jp/h1.htm)から採用すると、「大石泉すき」はそれぞれ「3画・5画・9画・2画・4画」です。実は姓名判断だと「す」を3画と数えるそうですが、「**どう見たって2画でしょうが**」といった理由により2画としました。

```python
def calc_stroke_score(x: str) -> int:
    p1: int = '大石泉すき'.find(x[0:1])
    p2: int = max('石泉すき'.find(x[1:2]), '石大'.find(x[1:2]))
    p3: int = max('泉すき'.find(x[2:3]), '泉石大'.find(x[2:3]))
    p4: int = max('すき'.find(x[3:4]), 'す泉石大'.find(x[3:4]))
    p5: int = 'きす泉石大'.find(x[4:5])

    sum = 0
    sum += 3 if p1 == 0 else 0
    sum += 5 if p2 == 0 else 0
    sum += 9 if p3 == 0 else 0
    sum += 2 if p4 == 0 else 0
    sum += 4 if p5 == 0 else 0

    return sum

    # 「上記2つのいずれも同数の場合、正位置にある文字の画数が多いものが勝つ。」
    stroke1 = calc_stroke_score(a)
    stroke2 = calc_stroke_score(b)
    if stroke1!= stroke2:
        if stroke1< stroke2:
            return 1
        if stroke1> stroke2:
            return -1
```

# 検証

　後はこれ全部を組み込めば完成！閉廷！……ではなく、「**全く同一の手じゃない場合は勝敗が付けられる**」ことを証明する必要があります。というわけで`3125×3125－3125＝9762500`通りを検証すれば……**エラーが大量に出ました。**

```
大大大大石/大大大大泉
大大大大石/大大大大す
大大大大石/大大大石大
大大大大石/大大大泉大
(中略)
287564通り
```

9762500通りからすると287564通りは3％弱ですが、無視することはできません。そこで第四のルールとして、「正位置にない文字の画数が少ないものが勝つ。」も追加します。

```
大大大大石/大大大石大
(中略)
24942通り
```

エラー数が1桁少なくなりましたが、できれば0にしたいところです。単純に文字列を比較する「a < b」的なコードを入れると当然エラーは消えますが、それはそれで**面白くない**ので、上手いルール付けが今後の課題となりました。

# 最終的なソースコード

```python
import collections
import itertools


def judge_hand(name: str) -> int:
    """役を判定する

    Parameters
    ----------
    name : str
        「大石泉すき」から重複を許して5文字取って並べたもの

    Returns
    -------
    int
        判定された役(0がロイヤルストレートフラッシュ、ブタが8)
    """

    # 各文字をバラす
    name_char = list(name)

    # 各文字の出現回数を数え、出現回数部分だけ抽出したもの。
    # 例：「大大すすき」→[2, 2, 1]
    char_count = collections.Counter(name_char).values()

    # 手役の判定処理
    if name == '大石泉すき':
        # 説明不要
        return 0
    elif len(set(name_char)) == 1:
        # uniq処理を施して1つしか残らない＝ファイブカード
        return 1
    elif 4 in char_count:
        # ある文字が4つある＝フォーカード
        return 2
    elif len(char_count) == 5:
        # 5種類ある＝元の文字列が5文字なので各文字ある＝ストレートフラッシュ
        return 3
    elif 2 in char_count and 3 in char_count:
        # 出現回数に「2」と「3」がある＝フルハウス
        return 4
    elif 'すき' in name:
        # すき
        return 5
    elif 3 in char_count:
        # 出現回数に「3」がある＝スリーカード
        return 6
    elif 2 in char_count and len(char_count) == 3:
        # 出現回数が3文字分あって「2」が含まれる＝消去法からツーペア
        return 7
    return 8


def calc_pos_score(x: str) -> int:
    p1: int = '大石泉すき'.find(x[0:1])
    p2: int = max('石泉すき'.find(x[1:2]), '石大'.find(x[1:2]))
    p3: int = max('泉すき'.find(x[2:3]), '泉石大'.find(x[2:3]))
    p4: int = max('すき'.find(x[3:4]), 'す泉石大'.find(x[3:4]))
    p5: int = 'きす泉石大'.find(x[4:5])

    sum = 0
    sum += 1 if p1 == 0 else 0
    sum += 1 if p2 == 0 else 0
    sum += 1 if p3 == 0 else 0
    sum += 1 if p4 == 0 else 0
    sum += 1 if p5 == 0 else 0

    return sum


def calc_stroke_score(x: str) -> int:
    p1: int = '大石泉すき'.find(x[0:1])
    p2: int = max('石泉すき'.find(x[1:2]), '石大'.find(x[1:2]))
    p3: int = max('泉すき'.find(x[2:3]), '泉石大'.find(x[2:3]))
    p4: int = max('すき'.find(x[3:4]), 'す泉石大'.find(x[3:4]))
    p5: int = 'きす泉石大'.find(x[4:5])

    sum = 0
    sum += 3 if p1 == 0 else 0
    sum += 5 if p2 == 0 else 0
    sum += 9 if p3 == 0 else 0
    sum += 2 if p4 == 0 else 0
    sum += 4 if p5 == 0 else 0

    return sum


def calc_stroke_not_score(x: str) -> int:
    p1: int = '大石泉すき'.find(x[0:1])
    p2: int = max('石泉すき'.find(x[1:2]), '石大'.find(x[1:2]))
    p3: int = max('泉すき'.find(x[2:3]), '泉石大'.find(x[2:3]))
    p4: int = max('すき'.find(x[3:4]), 'す泉石大'.find(x[3:4]))
    p5: int = 'きす泉石大'.find(x[4:5])

    stroke_dict = {
        "大": 3,
        "石": 5,
        "泉": 9,
        "す": 2,
        "き": 4
    }

    sum = 0
    sum += stroke_dict[x[0:1]] if p1 != 0 else 0
    sum += stroke_dict[x[1:2]] if p2 != 0 else 0
    sum += stroke_dict[x[2:3]] if p3 != 0 else 0
    sum += stroke_dict[x[3:4]] if p4 != 0 else 0
    sum += stroke_dict[x[4:5]] if p5 != 0 else 0

    return sum


def compare_hand(a: str, b: str) -> int:
    """手役を比較する

    Parameters
    ----------
    a : str
        「大石泉すき」から重複を許して5文字取って並べたもの1
    b : str
        「大石泉すき」から重複を許して5文字取って並べたもの2

    Returns
    -------
    int
        aが強ければ-1、同じなら0、bが強ければ1
    """

    if a == b:
        return 0

    # 役が異なれば当然そこで判定できる
    hand1 = judge_hand(a)
    hand2 = judge_hand(b)
    if hand1 != hand2:
        if hand1 < hand2:
            return -1
        if hand1 > hand2:
            return 1

    # 「同じ強さの場合「すき」が(より後ろで)揃っているものが勝つ。」
    pos1 = a.find('すき')
    pos2 = b.find('すき')
    if pos1 != pos2:
        if pos1 < pos2:
            return 1
        if pos1 > pos2:
            return -1

    # 「同じ強さですきの位置も同じの場合正位置に近い文字数が多いものが勝つ。」
    ssd1 = calc_pos_score(a)
    ssd2 = calc_pos_score(b)
    if ssd1 != ssd2:
        if ssd1 < ssd2:
            return 1
        if ssd1 > ssd2:
            return -1

    # 「上記2つのいずれも同数の場合、正位置にある文字の画数が多いものが勝つ。」
    stroke1 = calc_stroke_score(a)
    stroke2 = calc_stroke_score(b)
    if stroke1 != stroke2:
        if stroke1 < stroke2:
            return 1
        if stroke1 > stroke2:
            return -1

    # 「正位置にない文字の画数が少ないものが勝つ。」
    stroke1 = calc_stroke_not_score(a)
    stroke2 = calc_stroke_not_score(b)
    if stroke1 != stroke2:
        if stroke1 < stroke2:
            return -1
        if stroke1 > stroke2:
            return 1

    # 単純な比較関数
    return -1 if stroke1 < stroke2 else 1


# メインルーチン
if __name__ == '__main__':
    # テスト用
    print(compare_hand('大石泉きす', '大石大石泉'))  # -1
    print(compare_hand('泉す泉すき', '大大泉石石'))  # -1
    print(compare_hand('す泉太すき', '泉泉すきす'))  # -1
    print(compare_hand('大石すき泉', '泉大すき石'))  # -1
    print(compare_hand('泉泉石きき', '大大石泉泉'))  # -1
    print(compare_hand('すき泉きす', 'すき石石き'))  # -1


    # 以下、全通りをテストするためのコード


    # 使用する文字の種類
    chars = '大石泉すき'
    # itertools.productを利用して、重複順列を作り出し、1セットづつname_charに取り出す
    name_list = []
    for name_char in itertools.product(chars, repeat=5):
        # name_charの全文字を結合したものをnameとする
        name = ''.join(name_char)

        name_list.append(name)
    # 検証を行う
    sum = 0
    for a_name in name_list:
        for b_name in name_list:
            if a_name != b_name:
                result = compare_hand(a_name, b_name)
                if result == 0:
                    if sum < 10:
                        print(a_name + "/" + b_name)
                    sum += 1
    print("{0}通り".format(str(sum)))
```