麻雀やるより麻雀プログラムを考えるほうがが好き

# はじめに

リアルでもゲームでも時間が無くて麻雀をやらなくなったのですが、移動中とかに頭の中で麻雀のことを考えるのは結構好きです。

pythonで配列の要素の組み合わせを取得するitertoolsというものを知りました。
これって麻雀の判定に使えると思ったのでアイディアとしてストックしてた天和シミュレーターと簡単なCPUとして使うための何切るロジックを作ってみました。
記事にする前に検索してみたら他にも例があったのでネタとしては新鮮味に欠けるようですが、作ってみた部品群をまとめておきます。


# 牌の管理

IntEnumクラスで数値を持つ列挙型を扱えるので、これを利用して種類のIDと必要なメソッドをまとめて管理します。

クラス名をHAIにするかPAIにするか悩みましたが、標準読み(と思われる)のHAIにしておきます。英語だとtileになるらしいですがしっくりきませんでした。

メソッドも追加できるので、判定に必要な部品を実装しておきます。
赤ドラとか追加したかったらプロパティ増やせばいいと思います。



```python:MjHai.py

from enum import IntEnum

HAI_TYPE_NUM = 34
HAI_NUM = 34 * 4

HaiPrintTable = ('壱','二','三','四','五','六','七','八','九',
                '①','②','③','④','⑤','⑥','⑦','⑧','⑨',
                '１','２','３','４','５','６','７','８','９',
                '東','南','西','北',
                '白','發','中')


class HAIColor(IntEnum):
    '''種別'''
    INVALID = -1
    CHAR = 0  # manzu
    BAMBOO = 1  # pinzu
    CIRCLES = 2  # souzu
    WIND = 3
    DORAGON = 4


class HAI(IntEnum):
    INVALID = -1
    M1 = 0
    M2 = 1
    M3 = 2
    M4 = 3
    M5 = 4
    M6 = 5
    M7 = 6
    M8 = 7
    M9 = 8
    P1 = 9
    P2 = 10
    P3 = 11
    P4 = 12
    P5 = 13
    P6 = 14
    P7 = 15
    P8 = 16
    P9 = 17
    S1 = 18
    S2 = 19
    S3 = 20
    S4 = 21
    S5 = 22
    S6 = 23
    S7 = 24
    S8 = 25
    S9 = 26
    WE = 27  # wind - east
    WS = 28  # wind - south
    WW = 29  # wind - west
    WN = 30  # wind - north
    DW = 31  # Doragon - white
    DG = 32  # Doragon - green
    DR = 33  # Doragon - red

    def __repr__(self):
        # return self.name # 日本語非対応の場合はこちらを有効
        return HaiPrintTable[self]

    def color(self):
        if HAI.M1 <= self and self <= HAI.M9:
            return HAIColor.CHAR
        if HAI.P1 <= self and self <= HAI.P9:
            return HAIColor.BAMBOO
        if HAI.S1 <= self and self <= HAI.S9:
            return HAIColor.CIRCLES
        if HAI.WW <= self and self <= HAI.WN:
            return HAIColor.WIND
        if HAI.DW <= self and self <= HAI.DR:
            return HAIColor.DORAGON
        return HAIColor.INVALID


    def _offset_hai(self, offset):
        target_type = self.color()
        if target_type == HAIColor.WIND or target_type == HAIColor.DORAGON or target_type == HAIColor.INVALID:
            return HAI.INVALID

        try:
            temp = HAI(self + offset)
        except:
            return HAI.INVALID

        if target_type == temp.color():
            return temp

        return HAI.INVALID

    def prev(self, index=1):
        return self._offset_hai(index * -1)

    def next(self, index=1):
        return self._offset_hai(index)

```


# 天和シミュレーター


## 上がり判定ロジック


上がりを判定するためのAgariCheckerクラスを実装します。
以下の３つのパターンを判定する必要があります。

1. 国士無双

   一九字牌が１種類ずつ13個。残り１つも一九字牌となる組み合わせ。
   特殊なパターンなので、個別に判定するメソッドを用意します。


2. 七対子

   2,2,2,2,2,2,2の組み合わせ。
   特殊なパターンなので、個別に判定するメソッドを用意します。


3. 通常

   3,3,3,3,2の組み合わせ。
   再帰的なロジックで組み合わせを判定するメソッドを用意します。

## 天和シミュレーターの処理

mainの中にAgariCheckerを使った天和シミュレーターの処理を実装しておきます。

1. 牌をランダムに並べる

2. AgariCheckerで上がれる状態か判定する

3. 上がっていなかったら、1に戻る


```python:MjAgariChecker.py

from MjHai import HAI, HAI_NUM
from MjHaiUtils import HaiUtil
import itertools


class AgariChecker:
    def __init__(self, hais):
        self.hais = sorted(hais)
        self.agari_list = []
        self.scrap_list = []

    def _is_kokusi(self):
        # 13,1のセット
        kokusi = [HAI.M1, HAI.M9,
                  HAI.P1, HAI.P9,
                  HAI.S1, HAI.S9,
                  HAI.DW, HAI.DR, HAI.DG,
                  HAI.WW, HAI.WS, HAI.WE, HAI.WN]

        rest_hais = list(self.hais)
        for hai in kokusi:
            if hai in rest_hais:
                rest_hais.remove(hai)
            else:
                break

        if len(rest_hais) == 1:
            if rest_hais[0] in kokusi:
                tokens = []
                tokens.append(list(self.hais))
                self.agari_list.append(tokens)
                return True
        return False

    def _is_seven_pairs(self):
        # 2*7のセット
        rest_hais = list(self.hais)
        tokens = []
        for index in range(7):
            pairs_itr = itertools.combinations(rest_hais, 2)
            for pairs_candidate in pairs_itr:
                if HaiUtil.is_pairs(pairs_candidate):
                    rest_hais = HaiUtil.remove_hais(
                        rest_hais, pairs_candidate)
                    tokens.append(list(pairs_candidate))
                    break  # 他の組み合わせはない
                else:
                    return False

        self.agari_list.append(tokens)
        return True

    def _split_combinations(self, hais, tokens=[], commit_tokens=[]):
        '''haisの配列から組み合わせ有効なものを取り出す。再帰的になくなるまでやる'''
        results = list(commit_tokens)
        rest_hais = list(hais)

        token_size = 3
        if len(rest_hais) < 3:
            token_size = 2

        find_combinations = False

        itr = itertools.combinations(rest_hais, token_size)
        for candidate in itr:
            if HaiUtil.is_combinations(candidate):
                find_combinations = True
                next_hais = HaiUtil.remove_hais(rest_hais, candidate)
                next_tokens = list(tokens)
                next_tokens.append(candidate)
                if len(next_hais) > 0:
                    results = self._split_combinations(next_hais,
                                                    tokens=next_tokens,
                                                    commit_tokens=results)
                else:
                    next_tokens = sorted(next_tokens)
                    if not next_tokens in results:
                        results.append(next_tokens)

        if not find_combinations:
            # 対象となる組み合わせが無い場合、残りをscrap_listに登録
            self._set_scrap_list(hais)

        return results


    def _is_standerd(self):
        # 3,3,3,3,2のセット

        rest_hais = list(self.hais)
        self.agari_list.extend(self._split_combinations(rest_hais))

        if len(self.agari_list) > 0:
            return True
        return False

    def is_agari(self):
        result = False
        if self._is_kokusi():
            result = True
        if self._is_seven_pairs():
            result = True
        if self._is_standerd():
            result = True
        return result

    def _set_scrap_list(self, hais):
        # 余りリストへ登録
        if len(self.scrap_list) == 0:
            # 未登録なのでそのまま追加
            self.scrap_list.append(hais)
        else:
            # 登録済の場合
            if len(self.scrap_list[0]) > len(hais):
                # 候補の個数が少なくなる場合は、これまでの情報をリセットしてから登録
                self.scrap_list = []
                self.scrap_list.append(hais)
            elif len(self.scrap_list[0]) == len(hais):
                # 候補の個数が同じ場合はリストに追加
                if not hais in self.scrap_list:
                    self.scrap_list.append(hais)

if __name__ == '__main__':
    import random
    import time

    while True:
        hais = HaiUtil.hais_all()

        tehai = hais[: 14]
        print("\r{}".format(sorted(tehai)), end="")

        obj = AgariChecker(tehai)
        if obj.is_agari():
            print("\n OK.")

            for n, agari in enumerate(obj.agari_list):
                print("{} {}".format(n, sorted(agari)))
            break

```


牌操作の単機能のメソッドは別クラスにスタティックメソッドとして定義しています。


```python:MjHaiUtils.py

from MjHai import HAI, HAI_NUM, HAI_TYPE_NUM, HAIColor
import random

class HaiUtil:

    @staticmethod
    def find_count(hais, hai):
        count = 0
        for temp in hais:
            if temp is hai:
                count = count + 1
        return count


    @staticmethod
    def is_pairs(candidate):
        '''2個のhaiが同じか判定'''
        if len(candidate) == 2:
            if candidate[0] == candidate[1]:
                return True
        return False

    @staticmethod
    def is_triplets(candidate):
        '''3個のhaiが同じか判定'''
        if len(candidate) == 3:
            if candidate[0] == candidate[1] and candidate[0] == candidate[2]:
                return True
        return False

    @staticmethod
    def is_straight(candidate):
        '''3個が連番か判定'''
        if len(candidate) == 3:
            sorted_list = sorted(candidate)
            if sorted_list[0].next(1) == sorted_list[1] and sorted_list[0].next(2) == sorted_list[2]:  # 連番
                return True
        return False

    @staticmethod
    def is_combinations(candidate):
        if len(candidate) == 3:
            if HaiUtil.is_triplets(candidate):  # 3個同じ
                return True
            elif HaiUtil.is_straight(candidate):  # 連番
                return True
            return False
        elif len(candidate) == 2:
            return HaiUtil.is_pairs(candidate)  # 2個同じ
        return False

    @staticmethod
    def remove_hais(org_hais, remove_hais):
        '''org_haisからremove_haisに含まれるアイテムを削除する'''
        temp_hais = list(org_hais)
        for hai in remove_hais:
            temp_hais.remove(hai)
        return temp_hais

    @staticmethod
    def hais_all():
        hais = []
        for i in range(HAI_NUM):
            hais.append(HAI(i % HAI_TYPE_NUM))
        random.shuffle(hais)
        return hais


```

## 実行結果

あがるまでprintし続けます。結構時間かかります。

```

#python MjAgariChecker.py
[六, 七, 八, ⑤, ⑥, ⑦, ５, ６, ７, ７, ８, ９, 中, 中]
 OK.

```



# 簡易CPUの実装

## 何切る判定ロジック

AgariCheckerを継承して何切る判定ロジックを作ります。

今回は適当な対戦相手としてランダム切りよりまし程度のものを作ります。

AgariCheckerで上がりの組み合わせを作りますが、余ったものを切る対象として使います。

余ったものを比べてポイントを付けていきます。一番ポイントが低いものを優先して捨てることにします。


- 字が１個しかない場合は-100ポイント
- 同じものが２個あったら+5ポイント
- 1 or 9 だったら-1ポイント
- 同じ色、連番ものがあったら+10ポイント(ex 対象1で2がある場合)
- 同じ色、1つ飛び番号ものがあったら+5ポイント(ex 対象1で3がある場合)


## 簡易CPUの処理

１つ切ったら、１つツモるループを用意しました。

```python:MjDropCandidateChecker.py

from MjAgariChecker import AgariChecker
from MjHai import HAI, HAIColor
from MjHaiUtils import HaiUtil
import itertools

class DropCandidateChecker(AgariChecker):
    def __init__(self, hais):
        super().__init__(hais)

    def get_scrap_rank(self):
        '''仲間はずれリストを返す ※事前にis_agari()を呼んでおくこと'''
        point_list = []

        rest_hais = self.scrap_list[0]

        for index, hai in enumerate(rest_hais):
            point = 0

            if hai.color() == HAIColor.DORAGON or hai.color() == HAIColor.WIND:
                if HaiUtil.find_count(rest_hais, hai) == 1:
                    point -= 100

            if HaiUtil.find_count(rest_hais, hai) == 2:
                point += 5

            if hai.next(1) != HAI.INVALID:
                if hai.next(1) in rest_hais:
                    point += 10
            else:
                point -= 1
            if hai.next(2) != HAI.INVALID:
                if hai.next(2) in rest_hais:
                    point += 5

            if hai.prev(1) != HAI.INVALID:
                if hai.prev(1) in rest_hais:
                    point += 10
            else:
                point -= 1
            if hai.prev(2) != HAI.INVALID:
                if hai.prev(2) in rest_hais:
                    point += 5

            point_list.append({'point': point, 'value': hai})

        # pointでソート 不要なものが先頭になる
        drop_candidate_list = sorted(point_list, key=lambda x: x['point'])

        result = []
        for item in drop_candidate_list:
            result.append(HAI(item['value']))

        return result



if __name__ == '__main__':
    loop = 1000
    for _ in range(loop):
        hais = HaiUtil.hais_all()
        tehai = hais[: 13]
        yama = hais[13:]
        for i, next_hai in enumerate(yama):
            tehai.append(next_hai)
            print("\r{} {}".format(i, sorted(tehai)), end="")

            obj = DropCandidateChecker(tehai)
            if obj.is_agari():
                print(" OK.")
                for agari in obj.agari_list:
                    print(" {}".format(sorted(agari)))
                break

            scraps = obj.get_scrap_rank()

            tehai.remove(scraps[0])


```


## 実行結果


```
#python MjDropCandidateChecker.py
28 [三, 四, 五, 七, 八, 九, ④, ④, ⑥, ⑥, ⑥, ３, ４, ５] OK.
 [(三, 四, 五), (七, 八, 九), (④, ④), (⑥, ⑥, ⑥), (３, ４, ５)]
32 [五, 六, 七, ２, ２, ２, ４, ４, ４, ５, ５, ７, ８, ９] OK.
 [(五, 六, 七), (２, ２, ２), (４, ４, ４), (５, ５), (７, ８, ９)]
56 [①, ②, ③, ④, ⑤, ⑥, ４, ４, ５, ５, ６, ６, ７, ７] OK.
 [(①, ②, ③), (④, ⑤, ⑥), (４, ５, ６), (４, ５, ６), (７, ７)]
 [(①, ②, ③), (④, ⑤, ⑥), (４, ４), (５, ６, ７), (５, ６, ７)]
55 [四, 五, 六, ④, ④, ④, ５, ６, ６, ７, ７, ８, ８, ８] OK.
 [(四, 五, 六), (④, ④, ④), (５, ６, ７), (６, ７, ８), (８, ８)]
52 [壱, 二, 三, ①, ②, ③, ③, ④, ⑤, ⑥, ⑥, ５, ６, ７] OK.
 [(壱, 二, 三), (①, ②, ③), (③, ④, ⑤), (⑥, ⑥), (５, ６, ７)]
90 [壱, 壱, 二, 二, 三, 三, ①, ①, ②, ②, ③, ③, ８, ８] OK.
 [[壱, 壱], [二, 二], [三, 三], [①, ①], [②, ②], [③, ③], [８, ８]]
 [(壱, 二, 三), (壱, 二, 三), (①, ②, ③), (①, ②, ③), (８, ８)]
47 [五, 五, 五, ④, ⑤, ⑥, ２, ２, ２, ８, ８, ９, ９, ９] OK.
 [(五, 五, 五), (④, ⑤, ⑥), (２, ２, ２), (８, ８), (９, ９, ９)]
34 [六, 七, 八, ④, ⑤, ⑥, ⑥, ⑦, ⑧, １, ２, ３, ８, ８] OK.
 [(六, 七, 八), (④, ⑤, ⑥), (⑥, ⑦, ⑧), (１, ２, ３), (８, ８)]

（略）

```



# おわりに

itertools便利ですね。昔MFCで麻雀を実装した時はCList<>とか使ってかなり面倒な感じだったと記憶していますが、シンプルな感じでできたと思います。

今回作成した部品を使ってCPU対戦できる試作もしてあるのですが、記事にできるクオリティにするのにもう少し苦戦しそうな感じです。麻雀のルール複雑すぎてシンプルな実装にならないです。

次回更新する機会があれば役判定の部品でいきたいと思います。




# 麻雀開発シリーズ

[PlantUMLで麻雀の状態遷移図を書いた](https://qiita.com/magiclib/items/c37e2a576f38a2a8ecc6)