簡単な麻雀AIを作ってみた（準備編）


最近寒くなってきたなぁと思ってたら、体調崩して何年ぶりかに1週間近く寝込みました。
一人暮らしで体調崩すと食事もままならないし、実家に帰りたい...って気持ちになります。
ちなみに一週間も声が出ないと発狂しそうになります。

## はじめに
皆さんはオンラインで麻雀をする時、なんのアプリを使いますか？麻雀一番街？？MJ？？でも一番今ポピュラーな麻雀アプリは雀魂ですよね。（過激派）
さて本題です。
麻雀の勉強をするときにAIがお手本を示してくれたら楽だなって思いませんか？
そこで今回は簡単な麻雀AIを作ってみます。

## 環境構築
今回の主題でもなんでもないので、他のサイトでもみてください。
python 3.12が動けば大丈夫です。適宜、入っていないパッケージあればパッケージインストールはしてください。

## 麻雀とは
麻雀とは昔から多くの人に楽しまれて...
というお題目はこの記事を読もうと思う層にいらないと思いますがAIくんに聞いてみました。
```
麻雀（マージャン）は、136枚の牌（パイ）を用いて、4人で行う室内遊戯です。自分の手牌から役を作り、得点の高さと速さを競います。﻿
麻雀のルールは次のとおりです。
東（トン）、南（ナン）、西（シャー）、北（ペイ）の場に着いた4人に各13枚の牌が配られる。﻿
ルールに従って牌の組み合わせを作り、上がりを競う。﻿
最初にアガリの形にした人が点数をもらえ、最終的に1番多く点数を持っている人が勝ち
```
まぁつまり、
1. 牌をもらって
1. 組み合わせを作って
1. 最初に和了の形にすればいい

ってことですね。

簡単な麻雀AIなので今回は一局を想定して作ってみましょう。


## 麻雀が動く準備をする
一旦4人のプレイヤーに初期配牌を配らないとゲームが始まらないので、その準備をします。
配られた初期配牌が表示されている状態がゴールです。

```
import numpy as np

class Tile:
    # 牌の定義
    def __init__(self, suit, rank):
        self.suit = suit  
        self.rank = rank 

    def __repr__(self):
        return f"{self.suit}{self.rank}"
    
class Hand:
    # 手牌の定義
    def __init__(self):
        self.tiles = []

    def add_tile(self, tile):
        self.tiles.append(tile)

    def remove_tile(self, tile):
        self.tiles.remove(tile)

    def __repr__(self):
        return f"Hand({self.tiles})"
    
class Game:
    def __init__(self):
        self.players = [Hand() for _ in range(4)]
        self.wall = self.create_wall()

    def create_wall(self):
        suits = ['萬', '筒', '索']
        ranks = list(range(1, 10))
        honors = ['東', '南', '西', '北', '白', '發', '中']
        wall = [Tile(suit, rank) for suit in suits for rank in ranks] * 4
        wall += [Tile(honor, 1) for honor in honors] * 4
        np.random.shuffle(wall)
        return wall

    def deal_tiles(self):
        for _ in range(13):
            for player in self.players:
                player.add_tile(self.wall.pop())

    def __repr__(self):
        return f"Game(players={self.players}, wall={len(self.wall)} tiles left)"

class AIPlayer(Hand):
    def __repr__(self):
        sorted_tiles = sorted(self.tiles, key=lambda x: (x.suit, x.rank))
        return f"AIPlayer({sorted_tiles})"
    
class GameWithAI(Game):
    def __init__(self):
        super().__init__()
        self.players = [AIPlayer() for _ in range(4)]

    def play_game(self):
        self.deal_tiles()
        print("ゲーム終了")
        for i, player in enumerate(self.players):
            print(f"プレイヤー {i} の最終手牌: {player}")

if __name__ == "__main__":
    game = GameWithAI()
    game.play_game()
```

はい、これでとりあえず4人のプレイヤーに対して13枚づつ配られるようになりましたね。

## 一番簡単な戦略
麻雀AIにおいて一番簡単な戦略とはなんでしょう。そうです。ランダムに牌を捨てることです。ということでその方式を実装していきましょう。

このAIくんは手番が来たら一枚ツモってランダムに一枚捨てます。
```
class Game:
    def draw_tile(self, player):
        if len(self.wall) > 0:
            tile = self.wall.pop()
            player.add_tile(tile)
            return tile
        else:
            raise ValueError("No more tiles in the wall")
            
class AIPlayer(Hand):
    def choose_discard(self):
        # シンプルな戦略: ランダムに1枚捨てる
        return self.tiles.pop(np.random.randint(len(self.tiles)))

class GameWithAI(Game):
    def play_turn(self, player_index):
        player = self.players[player_index]
        try:
            drawn_tile = self.draw_tile(player)
            print(f"プレイヤー {player_index} がツモった牌 {drawn_tile}")
            discard = player.choose_discard()
            print(f"プレイヤー {player_index} が捨てる牌 {discard}")
        except ValueError as e:
            print(f"プレイヤー {player_index} がツモれません: {e}")

    def play_game(self):
        self.deal_tiles()
        turn = 0
        while len(self.wall) > 0:
            self.play_turn(turn % 4)
            turn += 1
        print("ゲーム終了")
        for i, player in enumerate(self.players):
            print(f"プレイヤー {i} の最終手牌: {player}")
```
今まで牌を毎ターンツモる概念がなかったので牌をツモるようにします。
その上でAIくんが牌を捨てます。
これを牌がなくなるまで繰り返していきます。
つまりとりあえず1局はこなせるようになりました。

## 和了るためには？
麻雀というゲームの性質上、和了というものが存在します。
ただ、今のままだとたとえ天和上がったとしても牌を捨ててしまいます。
そこで4面子1雀頭見つけたらそこでゲームが終了するようにしましょう。（鳴きがないので、最低でもツモが役になってますね。）
```
class Hand:
    def is_valid_hand(self):
        from collections import Counter

        def is_sequence(tiles):
            return len(tiles) == 3 and tiles[0].rank + 1 == tiles[1].rank and tiles[1].rank + 1 == tiles[2].rank

        def is_triplet(tiles):
            return len(tiles) == 3 and tiles[0].rank == tiles[1].rank == tiles[2].rank

        def can_form_melds(tiles):
            if len(tiles) == 0:
                return True
            if len(tiles) < 3:
                return False
            tiles.sort(key=lambda x: (x.suit, x.rank))
            for i in range(len(tiles) - 2):
                if is_sequence(tiles[i:i+3]) or is_triplet(tiles[i:i+3]):
                    new_tiles = tiles[:i] + tiles[i+3:]
                    if can_form_melds(new_tiles):
                        return True
            return False

        tile_counts = Counter((tile.suit, tile.rank) for tile in self.tiles)
        pairs = [tile for tile, count in tile_counts.items() if count >= 2]

        for pair in pairs:
            remaining_tiles = []
            for tile in self.tiles:
                if (tile.suit, tile.rank) == pair and tile_counts[pair] > 0:
                    tile_counts[pair] -= 1
                else:
                    remaining_tiles.append(tile)
            if can_form_melds(remaining_tiles):
                return True
        return False

class GameWithAI(Game):
    def play_turn(self, player_index):
        player = self.players[player_index]
        try:
            drawn_tile = self.draw_tile(player)
            print(f"プレイヤー {player_index} がツモった牌 {drawn_tile}")
            # ゲーム終了条件: 4面子1雀頭の形
            if player.is_valid_hand():
                print(f"プレイヤー {player_index} の手牌は4面子1雀頭の形です。")
                print(f"プレイヤー {player_index} の最終手牌: {player}")
                print("ゲーム終了")
                return True 
            discard = player.choose_discard()
            print(f"プレイヤー {player_index} が捨てる牌 {discard}")
            print(f"プレイヤー {player_index} の手牌: {player}")
        except ValueError as e:
            print(f"プレイヤー {player_index} がツモれません: {e}")
```
手牌に4面子1雀頭があれば強制的にツモるようになりました。これで天和、地和の見落としをせずに済みます。

## 王牌の実装
ここまで読んでお気づきかもしれませんが、実はこの麻雀、王牌がまだないんですね。
ということで王牌を実装していきます。

```
class Game:
    def __init__(self):
        self.players = [Hand() for _ in range(4)]
        self.wall = self.create_wall()
        self.dead_wall = []

    def deal_tiles(self):
        for _ in range(13):
            for player in self.players:
                player.add_tile(self.wall.pop())
        # 王牌の取り分け
        self.dead_wall = self.wall[-14:]
        self.wall = self.wall[:-14]
```
これで無事王牌が実装されましたね。（ドラは役処理の時に対応します）

## 四風連打の処理
麻雀には対局途中に流局になるパターンが（ルールによるが）3種類存在します。
- 九種九牌
- 四風連打
- 四積算了

このうち、四風連打はゲーム側の処理なので実装していきましょう。

```
class GameWithAI(Game):
    def __init__(self):
        super().__init__()
        self.players = [AIPlayer() for _ in range(4)]
        self.discards = [[] for _ in range(4)] 

    def is_wind_tile(self, tile):
        return tile.suit in ['東', '南', '西', '北']

    def play_turn(self, player_index):
        player = self.players[player_index]
        try:
            drawn_tile = self.draw_tile(player)
            print(f"プレイヤー {player_index} がツモった牌 {drawn_tile}")
            # ゲーム終了条件: 4面子1雀頭の形
            if player.is_valid_hand():
                print(f"プレイヤー {player_index} の手牌は4面子1雀頭の形です。")
                print(f"プレイヤー {player_index} の最終手牌: {player}")
                print("ゲーム終了")
                return True 
            discard = player.choose_discard()
            print(f"プレイヤー {player_index} が捨てる牌 {discard}")
            print(f"プレイヤー {player_index} の手牌: {player}")
            # 四風連打のチェック
            if len(self.discards[0]) == 1:
                first_discard = self.discards[0][0]
                if all(self.is_wind_tile(self.discards[i][0]) and self.discards[i][0].suit == first_discard.suit for i in range(4)):
                    print("四風連打が発生しました。ゲーム終了")
                    return True
        except ValueError as e:
            print(f"プレイヤー {player_index} がツモれません: {e}")
```

## 七対子と国士無双
ここまでで鳴きがないこと以外は、とりあえずゲーム"は"できるようになりました。
ただAIくんも天の管理役さんも役とか知らないので、まさに初心者の麻雀状態です。
ただ普通の麻雀と決定的に違うとこがまだありますよね？
そうです。国士無双と七対子があがることさえできません。
ということで他の役は点数計算のパートで整備するのですが、先にこの二つの役だけ上がれるようにしましょう。

```
class Role:
    # 七対子の判定
    def is_seven_pairs(tiles):
        if len(tiles) != 14:
            return False
        tile_counts = Counter((tile.suit, tile.rank) for tile in tiles)
        return all(count == 2 for count in tile_counts.values())
    
    # 国士無双の判定
    def is_thirteen_orphans(tiles):
        if len(tiles) != 14:
            return False
        required_tiles = set([
            ('萬', 1), ('萬', 9),
            ('筒', 1), ('筒', 9),
            ('索', 1), ('索', 9),
            ('東', 1), ('南', 1), ('西', 1), ('北', 1),
            ('白', 1), ('發', 1), ('中', 1)
        ])
        tile_set = set((tile.suit, tile.rank) for tile in tiles)
        return required_tiles.issubset(tile_set) and len(tile_set) == 13

class GameWithAI(Game):
    # ゲーム終了条件のチェック
    def check_game_end(self, player_index):
        player = self.players[player_index]
        # ゲーム終了条件: 4面子1雀頭の形
        if player.is_valid_hand():
            print(f"プレイヤー {player_index} の手牌は4面子1雀頭の形です。")
            print(f"プレイヤー {player_index} の最終手牌: {player}")
            print("ゲーム終了")
            return True
        # ゲーム終了条件: 七対子
        if Role.is_seven_pairs(player.tiles):
            print(f"プレイヤー {player_index} の手牌は七対子です。")
            print(f"プレイヤー {player_index} の最終手牌: {player}")
            print("ゲーム終了")
            return True
        # ゲーム終了条件: 国士無双
        if Role.is_thirteen_orphans(player.tiles):
            print(f"プレイヤー {player_index} の手牌は国士無双です。")
            print(f"プレイヤー {player_index} の最終手牌: {player}")
            print("ゲーム終了")
            return True
        return False
```
これで鳴きができない以外は普通の麻雀をしてくれるようになりましたね。

## 和了拒否について
ちゃんとここから役を覚えさせるパートに入るんですが、その前にゲーム側で和了判断をするんではなく、AIくんが決められるようにしましょう。（とはいっても簡単なAIくんは見逃しとかしません。）

今ゲーム側で判断してる部分をAIくんから和了宣言があったら終了するように改善しましょう。

```
class AIPlayer(Hand):
    def __init__(self):
        super().__init__()
        self.role = Role() 

    # あがりの判断
    def check_win(self):
        if self.is_valid_hand():
            return True
        if self.role.is_seven_pairs(self.tiles):
            return True
        if self.role.is_thirteen_orphans(self.tiles):
            return True
        return False

class GameWithAI(Game):
    # ゲーム終了条件のチェック
    def check_game_end(self, player_index):
        player = self.players[player_index]
        if player.check_win():
            print(f"プレイヤー {player_index} があがりました。")
            print(f"プレイヤー {player_index} の最終手牌: {player}")
            print("ゲーム終了")
            return True

    def __repr__(self):
        sorted_tiles = sorted(self.tiles, key=lambda x: (x.suit, x.rank))
        return f"AIPlayer({sorted_tiles})"
```
これでAIくんからの宣言を元に和了るようになりましたね！

## 最後に
ここまで作ってきたもののまとめを最後にもう一度載せておきます。途中のリファクタリングとか省略してるので、完コピだと動かないので。
[最新版githubはこちら]("https://github.com/schezo817/majyan-ai")


あれ、麻雀AIってAI使ってないだろ！とここまで読んだ方なら思うかもしれません。その通りです。でもタイトル見てみてください。準備編なのです。そう、本編はまた来年...。