ポケモンカードゲームの一人回し調整をPythonでシミュレートする


私は趣味でポケモンカードゲームというTCGをプレイしております。
ご存知のかたも多いかと思いますが、
・60枚のカードの束（デッキ）をつかう
・カードゲーム上でポケモン同士の対戦を行う
・タイプ相性や、カード同士のコンボがあつい
・２人対戦型のカードゲーム
という特徴のカードゲームです。

対戦で勝つには、環境のデッキやプレイを学んで
”何十回も”テストプレイしながら、カードを入れ替えて最強のデッキを目指して行く
ことが重要になります。
（調整とよばれています）

しかしながら、<S>周りにともだちが少ない</S>育児等であまり外出する時間がなくなかなか対戦の機会に恵まれません。
そういうときは、一人で二人分のデッキを持って、対戦シミュレートすることで調整を行っています
（一人回しとよばれています）

---

さてポケカプレイヤーにおける調整は
概ね個々人の感覚に基づいてカードの優劣を決めて、デッキから抜くカード、入れるカードを決めますが
調整が進むと数％の違いを、たかだか１〜２回の対戦シミュレートで優劣をつける段階にはいってきます。

皆様に置かれましては、そんな１〜２回のシミュレートで数％の結果の違いに有意差を見出すなんてありえへんとお思いでしょう。
私もそうでした。

幸いPythonが少しだけわかるので、この一人回しをPythonで実装してみたので紹介したいとおもいます。

---

わかりやすさのため、一昔前のデッキ「レジギガス」（D~Fレギュ）の例を出します。
このデッキは、簡単にいうと、場に６種類のレジが揃うと超強いのです。
ですので、なるべくはやくレジを６種類揃える（後攻１ターン目か先行２ターン目まで）ことで勝率があがりますし、レジ６種を揃えられる確率が計測目標になります。

さて、シミュレーターは「デッキ」「プレイヤー行動方針」「ゲームロジック＋目標値の計測」の３項目（４？）で成り立ってます。
それぞれ実装して行きます。

## デッキ

    ## デッキ定義
    deck_recipe = [
        # ポケ
        'レジギガス',   'レジギガス',
        'レジアイス',   'レジアイス',
        'レジロック',   'レジロック',
        'レジスチル',   'レジスチル',
        'レジエレキ',   'レジエレキ',
        'レジドラゴ',   'レジドラゴ',
        # エネ
        'オーロラエネルギー',       'オーロラエネルギー',   
        'オーロラエネルギー',       'オーロラエネルギー',   
        'スピード雷エネルギー',     'スピード雷エネルギー', 
        'キャプチャーエネルギー',    'キャプチャーエネルギー',
        'キャプチャーエネルギー', 'キャプチャーエネルギー',
        'ギフトエネルギー',         'ギフトエネルギー',         
        # サポ
        '博士の研究', '博士の研究', '博士の研究', '博士の研究',
        'マリィ', 'マリィ', 'マリィ','マリィ',
        'シロナ', 
        'セレナ', 'ボス',
        # グッズ
        'クイックボール','クイックボール','クイックボール','クイックボール',
        'ハイパーボール','ハイパーボール','ハイパーボール','ハイパーボール',
        'ヒスイのヘビーボール','ヒスイのヘビーボール',
        '回収ネット','回収ネット',    'あなぬけのひも','あなぬけのひも',
        'ふつうのつりざお', 'ふつうのつりざお', 'ふつうのつりざお', 'ふつうのつりざお',
        'こだわりベルト','こだわりベルト','ポケギア',
        # スタジアム
        '雪道','雪道','雪道', '嵐の山脈'
    ]

    # デッキチェック
    print(len(deck_recipe))
    assert(len(deck_recipe) == 60)

    # デッキクラス
    class deck_class:
        def __init__(self, deck_list):
            self.deck_list = deck_list
            self.my_deck = copy.deepcopy(self.deck_list)

        # シャッフル
        def shuffle(self):
            random.shuffle(self.my_deck)

        # n枚引く
        def draw(self, n):
            drawn_card = self.my_deck[:n]
            self.my_deck = self.my_deck[n:]
            return drawn_card

        # リセット
        def reset(self):
            self.my_deck = copy.deepcopy(self.deck_list)

        # サーチ操作。カードがあればそれを返す。なければNone
        def get(self, card):
            for c in self.my_deck:
                if (c == card):
                    self.my_deck.remove(c)
                    self.shuffle()
                    return c
            self.shuffle()
            return None




## プレイヤー行動

    # 場の定義。バトル場とベンチ
    class field:
        def __init__(self):
            self.top = None
            self.bench = []

        def set_top(self, pokemon):
            assert(self.top is None)
            self.top = pokemon

        def set_bench(self, pokemon):
            self.bench.append(pokemon)
            assert(len(self.bench) <= 5)

        def exists(self, pokemon):
            return (pokemon in self.bench) or (pokemon == self.top)


    # よく使う定数定義
    REGIS = ['レジギガス','レジアイス','レジロック','レジスチル','レジドラゴ','レジエレキ']
    REGIGATES = ['レジアイス','レジロック','レジスチル']
    DRAW_SUPPORTS = ['博士の研究', 'セレナ', 'マリィ', 'シロナ', 'ヒガナ']
    GUDS = ['クイックボール','ハイパーボール','ヒスイのヘビーボール','エネくじ','ポケギア',
        '回収ネット','あなぬけのひも','ふつうのつりざお','こだわりベルト',
    ]
    class player_class:
        def __init__(self, deck_list):
            self.deck = deck_class(deck_list)
            self.field = field()
            self.hands = []
            self.trash = []
            self.sidecards = []

        def setup(self):
            self.deck.reset()
            self.deck.shuffle()
            self.field = field()
            self.hands = self.deck.draw(7)
            self.sidecards = self.deck.draw(6)
            self.used_support = False
            self.used_energy = False
            self.used_hihou = False
            self.used_studium = False
            self.can_use_kotobukimura = False
            self.set_energys = []
            self.set_regigates_energy = None
            self.set_gigas_energys = []

            top_pokemon = self.choose_top_pokemon()
            if top_pokemon is None:
                return False
            self.field.set_top(top_pokemon)

            return True

    # 手札の構成によって、最初に出すポケモンを決める。たねなしならNone
    def choose_top_pokemon(self):
        ## 手札の構成によって選出するポケモンを変えるロジックを実装する
        ## 長いので略
        
        if (energy_num > 0):
            if has_regirock:
                return 'レジロック'
            elif has_registeel:
                return 'レジスチル'
            elif has_regiice:
                return 'レジアイス'

        if has_regidrago:
            return 'レジドラゴ'
        elif has_regirock:
            return 'レジロック'
        elif has_registeel:
            return 'レジスチル'
        elif has_regiice:
            return 'レジアイス'
        elif has_regieleki:
            return 'レジエレキ'
        elif has_regigigas:
            return 'レジギガス'

        return None

    def turn_start(self, s, turn):
        self.draw(1)
        self.used_support = (s == '先攻') and (turn == 0)
        self.used_energy = False
        self.used_hihou = False
        self.used_studium = False
        self.can_use_kotobukimura = False

    ### ユーティリティ

    def draw(self, n):
        self.hands += self.deck.draw(n)

    def has_support(self):
        for card in self.hands:
            if card in DRAW_SUPPORTS:
                return True
        return False

    def hand_to_trash(self, card):
        self.hands.remove(card)
        self.trash.append(card)

    #####
    # 不在レジのリスト
    def get_unexists_regis(self):
        need_regis = []

        for regi in REGIS:
            if (self.field.exists(regi) == False):
                need_regis.append(regi)
        # もしスピード雷効果が使えそうなら、レジエレキをサーチ順の最初に変更する。
        if (self.used_energy == False) and ('スピード雷エネルギー' in self.hands) and ('レジエレキ' in need_regis):
            need_regis.remove('レジエレキ')
            need_regis = ['レジエレキ'] + need_regis            

        return need_regis

    # 必要なレジを1体、山札からサーチしてベンチに出す操作。
    def call_regi(self, need_regis):

        for regi in need_regis:
            card = self.deck.get(regi)
            if card is not None:
                self.field.set_bench(card)
                break

    # 手札から不要なカードを選出するロジックを実装する
    # can_call ... 山札からサーチできる札がある場合はTrue
    def search_trashable_cards(self, need_num, can_call):
        trash_cards = []

        # 不要なエネのサーチ
        # 不要なサポのサーチ
        # それ以外のカードのサーチ
        # をそれぞれ実装する。ながいので略。

        return trash_cards

    # 必要なレジを集めつつ、エネをトラッシュに落とすことを考えてプレイする。
    # まだ、できるターン内アクションがあればTrueを返す。
    def do_action(self, turn):

        # どのレジが欲しいか
        need_regis = self.get_unexists_regis()

        # ハンドにレジが入ればベンチに出す
        for regi in need_regis:
            if (regi in self.hands):
                self.hands.remove(regi)
                self.field.set_bench(regi)
                return True
        # .. 移行、１ターン内に行動する指針をロジックに落として実装する。
        # ながいので略。

        return False

    # 技宣言。ここではレジゲート・コトブキ村のみ
    def do_attack(self):
        if self.can_use_kotobukimura:
            self.deck.my_deck += self.hands
            self.hands = []
            self.deck.shuffle()
            self.draw(5)

        else:
            can_attack = (self.field.top in REGIGATES) and (self.set_regigates_energy is not None)

            if (can_attack):
                need_regis = self.get_unexists_regis()
                self.call_regi(need_regis)       
            
    # 目標達成。6体揃って、必要なエネが揃って、ワザが打てる状態。
    def goal(self):
        for regi in REGIS:
            if (self.field.exists(regi) == False):
                return False

        aurola_count = self.trash.count('オーロラエネルギー') + self.set_energys.count('オーロラエネルギー')
        eleki_count =  self.trash.count('スピード雷エネルギー') + self.set_energys.count('スピード雷エネルギー')
        energy_count = sum('エネルギー' in x for x in self.trash) + len(self.set_energys)
        gigas_ene_num = len(self.set_gigas_energys)
        for c in self.trash:
            if c == 'ツインエネルギー':
                gigas_ene_num += 1
                energy_count += 1
        for c in self.set_gigas_energys:
            if c == 'ツインエネルギー':
                gigas_ene_num += 1
                energy_count += 1 
        can_attack_aurola = (aurola_count >= 2) and (energy_count >= 3)
        can_attack_eleki =  ((aurola_count + eleki_count) >= 2) and (energy_count >= 3)
        can_attack_gigas =  (energy_count >= 5) and (gigas_ene_num >= 2)

        if ('回収ネット' in self.hands) or ('あなぬけのひも' in self.hands):
            return (can_attack_aurola or can_attack_eleki or can_attack_gigas)
        if (self.field.top == 'レジギガス') and can_attack_gigas:
            return True
        if (self.field.top == 'レジエレキ') and can_attack_eleki:
            return True
        if can_attack_aurola:
            return True

        return False


## ゲームロジックと計測目標の実装


    if __name__ == "__main__":
        player = player_class(deck_recipe)
        try_num = 10000
        for s in ['先攻', '後攻']:
            print(s)
            ok = [0, 0, 0]
            has_support_count = [0, 0, 0]
            ng = 0

            for i in tqdm(range(try_num)):
                retval = player.setup()
                if (retval is False):
                    ng += 1
                    continue

                # ターンの行動を定義する
                for turn in range(len(ok)):
                    player.turn_start(s, turn)

                    while (player.do_action(turn)):
                        pass

                    if (((s == '先攻') and (turn == 0)) is False) and (player.used_support):
                        has_support_count[turn] += 1

                    if (player.goal()):
                        ok[turn] += 1
                        break

                    player.do_attack()

            # 計測する目標値は、
            # レジが6種揃う確率に加えて、最初にたたかうポケモンがいないマリガン率と、
            # １ターン目をブーストするサポートカードを使える確率を見ています。
            print('マリガン率', str(np.round(ng / try_num * 100, 2)) + "%")
            ok_try_num = try_num - ng
            first_support_turn = 1 if (s=='先攻') else 0
            print('サポ打てる率', str(np.round(has_support_count[first_support_turn] / ok_try_num * 100, 3)) + "%")
            print('レジ揃った率 1ターン目', str(np.round(ok[0] / ok_try_num * 100, 3)) + "%")
            print('レジ揃った率 2ターン目', str(np.round(ok[1] / ok_try_num * 100, 3)) + "%")
            print('レジ揃った率 3ターン目', str(np.round(ok[2] / ok_try_num * 100, 3)) + "%")
    
        pass


------
## 動かしてみる

    $ python gigas.py 
    60
    先攻
    100%|████████████████████████████████████| 10000/10000 [01:24<00:00, 117.80it/s]
    マリガン率 19.06%
    サポ打てる率 81.814%
    レジ揃った率 1ターン目 3.496%
    レジ揃った率 2ターン目 57.611%
    レジ揃った率 3ターン目 28.811%
    後攻
    100%|█████████████████████████████████████| 10000/10000 [04:25<00:00, 37.62it/s]
    マリガン率 18.39%
    サポ打てる率 78.557%
    レジ揃った率 1ターン目 30.266%
    レジ揃った率 2ターン目 48.523%
    レジ揃った率 3ターン目 13.834%

やったね！

------
## 効果

* 何時間という調整が（一回つくってしまえば）数分で終わる
* 統計的に十分に有意差の出る試行回数を実行できる
* このシーズンの日本チャンプが使っていたレジギガスデッキをこのプログラムにかけたところ、後１で目標達成する確率は２０％台前半だった（と記憶している）が、
このシミュレータを使って調整していた私のレジギガスは３０％まで改善できた。
（つまり日本チャンプよりも、自分のプレイングにあってる構築に調整することできた）


というところで、ちゃんと効果のでたシミュレータをPythonで組むことができました。

ただ、現状ではあまり応用が効かないやり方だし、実装のロジックもごりっと書かないといけないので
汎用性をもたせたやりかたを模索したいなーと考えています。