背景
『Pokémon Trading Card Game Pocket(ポケポケ)』では、ゲームの開始時に手札として5枚のカードが配られます。その際、手札には必ず「たねポケが1枚以上含まれる」ようになっています。しかし、実際にどのようなロジックでこの処理が行われているかはユーザーに公開されていません。@Davoiさんの記事ではこの処理について以下の3つが考察されています。
-
初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く
-
山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す
-
山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える
これら3つのどれを採用していても、最初に「たねポケが1枚以上含まれる」という点は同じですが、「たねポケを2枚引く確率」や「ある特定のたねポケのみを引く確率」などはロジックごとに異なります。@Davoiさんの記事では、3つのロジックについての検証が行われていましたが、試行回数がやや少なく、統計的有意性を判断するには不十分でした。
そこで本記事では、どのロジックが実際の挙動に最も近いかを明らかにするため、以下の手順で検証しました。
-
pyautoguiにより、2種類のデッキでそれぞれ1,000回ずつ自動で対戦 -
OpenCVの画像マッチングで、スクリーンショットからカードの種類と枚数を自動で判定 - 集計結果を理論値と比較し、Z検定により有意差の有無を評価
各手順で用いたプログラムは記事末尾の使用したプログラムに掲載しています。
検証対象
検証1:たねポケを2枚引く確率
使用したデッキ
まず、@Davoiさんの記事でも行われている、たねポケが2枚のみのデッキを用いて、最初に「たねポケを2枚引く確率」について検証します。使用したデッキは以下の通りです。
図1. 検証1で使用したデッキ(たねポケ2枚、その他18枚)
集計結果
以下の表は、1,000回の対戦において、手札に含まれていた各カードの枚数別出現回数をまとめたものです。たねポケを2枚引いた回数は62回で、全体の6.2%となりました。
| カード名 | 0枚 | 1枚 | 2枚 | 枚数期待値 |
|---|---|---|---|---|
| コイキング | 0 | 938 | 62 | 1.062 |
| きずぐすり | 604 | 352 | 44 | 0.440 |
| スピーダー | 605 | 353 | 42 | 0.437 |
| モンスターボール | 590 | 370 | 40 | 0.450 |
| ハンドスコープ | 571 | 388 | 41 | 0.470 |
| ポケモン図鑑 | 610 | 347 | 43 | 0.433 |
| レッドカード | 644 | 325 | 31 | 0.387 |
| 幻の石板 | 628 | 331 | 41 | 0.413 |
| ポケモンの笛 | 593 | 365 | 42 | 0.449 |
| 博士の研究 | 588 | 365 | 47 | 0.459 |
理論値計算
続いて、3つのロジックについて、「たねポケを2枚引く確率」を理論的に求めます。
ロジック1
初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く
この場合は、「山札20枚からたねポケ1枚を除き、残った19枚から4枚を引いた時にもう1枚のたねポケを引く確率」で考えることができます。このロジックの場合、「たねポケを2枚引く確率」は21.1%となります。
\begin{align}
p_1 &= \frac{たねポケ2枚以外のカード3枚の組み合わせ}{カード4枚の組み合わせ} \\
&= \frac{_{18}C_3}{_{19}C_4} \\
&= 0.21053
\end{align}
ロジック2
山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す
この場合は、「山札20枚から5枚引いてたねポケを1枚以上引いた場合に、たねポケを2枚とも引いている確率」で考えることができます。このロジックの場合、「たねポケを2枚引く確率」は11.8%となります。
\begin{align}
p_2 &= \frac{たねポケ2枚以外のカード3枚の組み合わせ}{カード5枚中たねポケが1枚以上含まれる組み合わせ} \\
&= \frac{_{18}C_3}{_{20}C_5 - {}_{18}C_5} \\
&= 0.11765
\end{align}
ロジック3
山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える
この場合は、「山札20枚から5枚引いて、たねポケを2枚とも引いている確率」と考えることができます。このロジックの場合、「たねポケを2枚引く確率」は5.26%となります。
\begin{align}
p_3 &= \frac{たねポケ2枚以外のカード3枚の組み合わせ}{カード5枚の組み合わせ} \\
&= \frac{_{18}C_3}{_{20}C_5} \\
&= 0.05263
\end{align}
統計的検定
集計結果と各ロジックによって導かれた理論値との間に、統計的に有意な差が存在するかを検定します。
まず、1回の試行で「たねポケを2枚引く」確率を$p$とし、このとき成功(たねポケを2枚引く)を$1$、失敗(それ以外)を$0$とする確率変数$X \in \lbrace 0, 1 \rbrace$を定義します。この試行を独立に$n$回繰り返すと、成功回数$K= \sum_{j=1}^{n} X_j$は二項分布$Bin(n, p)$に従います。
ここで、$n$が十分大きい場合、二項分布はその期待値$\mu=np$, 分散$\sigma^2=np(1-p)$に基づいて正規分布に近似できます。つまり、$K$を標準正規分布に変換した統計量$z$は、
z = \frac{K - np}{\sqrt{np(1-p)}}
と導出でき、この$z$は近似的に標準正規分布$N(0,1)$に従うと考えられます。
ここで、帰無仮説$H_0$を「たねポケを2枚引く確率が理論通り$p$である」とし、対立仮説を「たねポケを2枚引く確率が$p$ではない」と定義します。有意水準は$\alpha=0.05$(両側検定)とし、計算された$z$に対応する$p$値が$\alpha$より小さい場合は、$H_0$を棄却します。
以下は、集計結果に基づいて検定を行った結果です。試行回数は$n = 1000$、成功回数は$K = 62$です。
ロジック1
理論値:$p = 0.21053$
z = \frac{62 - 210.53}{\sqrt{210.53(1-0.21053)}} = -11.521
このときの$p$値は$p \approx 1.037 \times 10^{-28} \%$となり、有意水準$\alpha=5\%$を大きく下回ります。よって、帰無仮説$H_0$は棄却され、ロジック1は有意に異なると判断できます*
ロジック2
理論値:$p = 0.11765$
z = \frac{62 - 117.65}{\sqrt{117.65(1-0.11765)}} = -5.462
このときの$p$値は$p \approx 4.715 \times 10^{-6} \%$となり、有意水準$\alpha=5\%$を大きく下回ります。よって、帰無仮説$H_0$は棄却され、ロジック2は有意に異なると判断できます。
ロジック3
理論値:$p = 0.05263$
z = \frac{62 - 52.63}{\sqrt{52.63(1-0.05263)}} = 1.327
このときの$p$値は$p \approx 18.46\%$となり、有意水準$\alpha=5\%$を十分に上回ります。よって、帰無仮説$H_0$は棄却されず、ロジック3は理論値と集計結果の間に統計的有意差は見られません。
以上の結果から、ロジック3のみが集計結果と統計的に矛盾しないと判断されます。
検証2:ある特定のたねポケのみを引く確率
使用したデッキ
次に、前シーズン(A3)にXで話題になった、ダークライギラティナデッキにネッコアラを1枚入れたとき、最初に「ネッコアラのみを引く確率」について検証します。使用したデッキは以下の通りです。
図2. 検証2で使用したデッキ(ネッコアラ1枚、ダークライ2枚、ギラティナ2枚、その他15枚)
集計結果
以下の表は、1,000回の対戦において手札に含まれていたたねポケの組み合わせ別の出現回数をまとめたものです。ネッコアラのみを引いた回数は128回で、全体の12.8%となりました。
| ネッコアラ | ダークライ | ギラティナ | 回数 |
|---|---|---|---|
| なし | 1枚以上 | なし | 294 |
| なし | なし | 1枚以上 | 285 |
| なし | 1枚以上 | 1枚以上 | 130 |
| 1枚 | なし | なし | 128 |
| 1枚 | 1枚以上 | なし | 72 |
| 1枚 | なし | 1枚以上 | 68 |
| 1枚 | 1枚以上 | 1枚以上 | 23 |
理論値計算
それぞれのロジックについて、「ネッコアラのみを引く確率」を理論的に求めます。
ロジック1
初めにたねポケの中から1枚引き、残り4枚をたねポケ含めた全ての山札から引く
この場合は、「初めのたねポケとしてネッコアラを引き、残った19枚から4枚を引いた時にたねポケが含まれない確率」で考えることができます。このロジックの場合、「ネッコアラのみを引く確率」は7.04%となります。
\begin{align}
p_1 &= 初めにネッコアラを引く確率 \times \frac{カード4枚中たねポケが含まれない組み合わせ}{カード4枚の組み合わせ} \\
&= \frac{1}{5} \times \frac{_{15}C_4}{_{19}C_4} \\
&= 0.07043
\end{align}
ロジック2
山札から5枚引き、その中にたねポケが含まれなかった場合、再度山札から5枚引き直す
この場合は、「山札20枚から5枚引いてたねポケを1枚以上引いた場合に、ネッコアラのみを引いている確率」で考えることができます。このロジックの場合、「ネッコアラのみを引く確率」は10.9%となります。
\begin{align}
p_2 &= \frac{カード4枚中たねポケが含まれない組み合わせ}{カード5枚中たねポケが1枚以上含まれる組み合わせ} \\
&= \frac{_{15}C_4}{_{20}C_5 - {}_{15}C_5} \\
&= 0.10919
\end{align}
ロジック3
山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替える
この場合は、「山札20枚から5枚引いてネッコアラのみを引く確率と、たねポケが引けなかったときにネッコアラを引く確率を足したもの」と考えることができます。このロジックの場合、「ネッコアラのみを引く確率」は12.7%となります。
\begin{align}
p_3 &= \frac{カード4枚中たねポケが含まれない組み合わせ}{カード5枚の組み合わせ} \\
&+ \frac{カード5枚中たねポケが含まれない組み合わせ}{カード5枚の組み合わせ} \times 入れ替えた1枚がネッコアラの確率 \\
&= \frac{_{15}C_4}{_{20}C_5} + \frac{_{15}C_5}{_{20}C_5} \times\frac{1}{5} \\
&= 0.12678
\end{align}
統計的検定
検証2についても、集計結果と理論値との間に統計的に有意な差が存在するかを検定します。
ここでは、1回の試行で「ネッコアラのみを引く」確率を$p$とし、このとき成功を$1$、失敗を$0$とする確率変数$X \in \lbrace 0, 1 \rbrace$を定義します。この試行を独立に$n$回繰り返すと、成功回数$K= \sum_{j=1}^{n} X_j$は二項分布$Bin(n, p)$に従います。$n$が十分大きい場合、統計量$z$は、
z = \frac{K - np}{\sqrt{np(1-p)}}
と導出でき、標準正規分布$N(0,1)$に従う確率変数として扱うことができます。
検証2では、帰無仮説$H_0$を「ネッコアラのみを引く確率が$p$である」、対立仮説を「ネッコアラのみを引く確率が$p$ではない」と定義し、有意水準$\alpha=0.05$の両側検定を行います。計算された$z$に対応する$p$値が$\alpha$より小さい場合は、$H_0$を棄却します。
以下は、集計結果に基づいて検定を行った結果です。試行回数は$n = 1000$、成功回数は$K = 128$です。
ロジック1
理論値:$p = 0.07043$
z = \frac{128 - 70.43}{\sqrt{70.43(1-0.07043)}} = 7.114
このときの$p$値は$p \approx 1.124 \times 10^{-10} \%$となり、有意水準$\alpha=5\%$を大きく下回ります。よって、帰無仮説$H_0$は棄却され、ロジック1は有意に異なると判断できます。
ロジック2
理論値:$p = 0.10919$
z = \frac{128 - 109.19}{\sqrt{109.19(1-0.10919)}} = 1.907
このときの$p$値は$p \approx 5.651\%$となり、有意水準$\alpha=5\%$をわずかに上回り、帰無仮説$H_0$はぎりぎり棄却されません。
ロジック3
理論値:$p = 0.12678$
z = \frac{128 - 126.78}{\sqrt{126.78(1-0.12678)}} = 0.116
このときの$p$値は$p \approx 90.77\%$となり、有意水準$\alpha=5\%$を十分に上回ります。よって、帰無仮説$H_0$は棄却されず、ロジック3は理論値と集計結果の間に統計的有意差は見られません。
以上の結果から、ロジック2とロジック3はいずれも統計的に有意差が見られませんでしたが、検定結果の$p$値の大きさを踏まえると、ロジック3のほうがより実際の挙動と近いと考えられます。
まとめ
たねポケが1枚以上含まれるときに使用されるロジックは、ロジック3の山札から5枚引き、その中にたねポケが含まれなかった場合、うち1枚をたねポケ1枚と入れ替えるであると統計的に裏付けられました。特に、検証1では、ロジック1、2が大きく乖離する一方で、ロジック3のみが理論値と非常に近い値を示しました。
これは、本家『Pokémon Trading Card Game(ポケカ)』で採用されているマリガン(ロジック2)とは異なります。ポケポケでは、よりテンポよくゲームを開始できる仕様になっていると考えられます。
使用したプログラム
それぞれの手順で用いたプログラムを載せておきます。
手順1:pyautoguiによる自動対戦
pyautoguiを用いてBlueStacks上の「ひとりで」モード対戦を自動化させ、2種類のデッキで1,000回ずつ、合計2,000回、手札のスクリーンショットを取得しました。1回の対戦に要する時間は約36秒であるため、約20時間ほどかかりました。
import pyautogui
import time
import glob
import os
import sys
import cv2
pyautogui.FAILSAFE = True # 緊急停止用(マウスを左上に移動)
# 1回分の対戦を行い、終了までの一連の操作(デッキ選択・バトル開始・降参など)を自動で実行する
def run_cycle():
print('対戦デッキ選択')
pyautogui.click(942, 499) # 対戦デッキ選択
time.sleep(4)
print('バトル開始')
pyautogui.click(942, 883) # バトル開始
time.sleep(14)
print('スクリーンショット')
pyautogui.hotkey('ctrl', 'shift', 's') # スクリーンショット
time.sleep(2)
print('メニュー')
pyautogui.click(705, 862) # メニュー
time.sleep(1)
print('降参ボタン1回目')
pyautogui.click(904, 686) # 降参ボタン1回目
time.sleep(1)
print('降参ボタン2回目')
pyautogui.click(942, 606) # 降参ボタン2回目
time.sleep(8)
print('タップ')
pyautogui.click(942, 974) # タップ
time.sleep(2)
print('次へ')
pyautogui.click(942, 974) # 次へ
time.sleep(4)
# これらの処理を1000回繰り返して実行する
def main():
n = 1000 # 対戦回数
time.sleep(3) # 開始までの猶予時間
for i in range(n):
print(f"\n--- {i + 1} 回目の実行 ---")
run_cycle() # 対戦実行
print(f"\n{n}回の自動操作が完了しました。")
if __name__ == '__main__':
main()
手順2:画像マッチングによるカード枚数判定
当初はOCR(光学文字認識)によってスクリーンショットからカード名を読み取ろうとしましたが、文字認識の精度が足りませんでした。そこで、カードの一部分をテンプレート画像として用意し、OpenCVの画像マッチングでスクリーンショット内に該当部分が何箇所あるかを検出しました。
import cv2
import numpy as np
import os
import glob
import csv
# 画像マッチングによって得られた候補点`points`のうち、近すぎて重複しているものをまとめて1つにする
def suppress_close_matches(points, distance_thresh=10):
filtered = []
for pt in points:
if all(np.linalg.norm(np.array(pt) - np.array(other)) > distance_thresh for other in filtered):
filtered.append(pt)
return filtered
# スクリーンショットを読み込み、各テンプレートについて一致箇所を検出する。
# 各カードの枚数が0~2枚の範囲に収まっている、かつカードの合計が5枚になっているかを確認し、
# 問題なければoutput_dirへ、異常があればanomaly_dirへ検出結果を保存する。
# 最後に全データをCSVに保存する
def process_images(image_pattern, templates, output_dir, anomaly_dir):
# 入力画像一覧
image_paths = glob.glob(image_pattern)
if not image_paths:
print("画像が見つかりませんでした。")
return
print(f"画像枚数: {len(image_paths)}")
# 出力ディレクトリ
os.makedirs(output_dir, exist_ok=True)
os.makedirs(anomaly_dir, exist_ok=True)
# 全体結果を格納
all_results = []
template_names = list(templates.keys())
# 各画像の処理
for main_path in image_paths:
filename = os.path.splitext(os.path.basename(main_path))[0]
print(f"\n画像処理中: {filename}")
# 入力画像を読み込み
main_img = cv2.imread(main_path)
if main_img is None:
print(f"読み込み失敗: {main_path}")
continue
main_color = main_img.copy()
counts = {}
# 各テンプレート画像で探索
for name, data in templates.items():
template_path = data["path"]
threshold = data["threshold"]
if not os.path.exists(template_path):
print(f"{name}: テンプレートが見つかりません")
counts[name] = 0
continue
# テンプレート画像を読み込み
template = cv2.imread(template_path)
h, w = template.shape[:2]
# テンプレートマッチング実行
res = cv2.matchTemplate(main_color, template, cv2.TM_CCOEFF_NORMED)
# 指定された閾値以上の座標を抽出
loc = np.where(res >= threshold)
raw_points = list(zip(*loc[::-1]))
# 近すぎるマッチを除去
filtered_pts = suppress_close_matches(raw_points, distance_thresh=max(w, h) * 0.5)
# 有効な検出数を記録
count = len(filtered_pts)
counts[name] = count
# 検出箇所に赤い矩形を描画
for pt in filtered_pts:
cv2.rectangle(main_img, pt, (pt[0] + w, pt[1] + h), (0, 0, 255), 2)
# 合計数
total = sum(counts.values())
# 保存先フォルダに画像保存
card_counts_valid = all(0 <= v <= 2 for v in counts.values()) # 各カードの枚数が0〜2枚の範囲にあるか確認
if total == 5 and card_counts_valid: # 合計枚数が5枚かを確認
save_dir = output_dir
else:
save_dir = anomaly_dir
summary_line = ', '.join([f"{k}: {v}" for k, v in counts.items()])
print(f"バグ検出: {filename} | カード種類: {summary_line}, 合計: {total} 枚")
result_img_path = os.path.join(save_dir, f"{filename}_result.png")
cv2.imwrite(result_img_path, main_img)
# 結果をall_resultsに追加
row = [filename] + [counts.get(name, 0) for name in template_names]
all_results.append(row)
# 最後にCSV出力
summary_path = os.path.join(output_dir, "summary.csv")
with open(summary_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
header = ["name"] + template_names
writer.writerow(header)
writer.writerows(all_results)
# ディレクトリを定義して実行する
def main():
# テンプレート画像
templates = {
"hoge": {"path": "./templates/hoge.png", "threshold": 0.7},
"fuga": {"path": "./templates/fuga.png", "threshold": 0.8},
}
image_pattern = "./pictures/*" # 手札画像
output_dir = "./output" # 成功した画像を出力
anomaly_dir = "./anomaly" # バグ検出した画像を出力
process_images(image_pattern, templates, output_dir, anomaly_dir)
if __name__ == '__main__':
main()
以下は、画像マッチングによりカードを検出した結果です。テンプレートに一致した領域には赤枠が描画されています。
図3. カード検出の結果(赤枠は各カードに対応するテンプレートが一致した領域)
手順3:Z検定による有意差の検証
手順2で得られた集計結果をもとに、3つのロジックについて、Z検定を用いて有意差の有無を判定します。
import pandas as pd
import math
# 試行回数n、成功回数k、仮説となる理論確率p0から両側Z検定を行う
def perform_z_test(n, k, p0, alpha=0.05):
def phi(z):
return 0.5 * (1 + math.erf(z / math.sqrt(2)))
p_hat = k / n
var = p0 * (1 - p0) / n
z = (p_hat - p0) / math.sqrt(var)
p_value = 2 * min(phi(z), 1 - phi(z))
print(f"p = {p0:.5f}")
print(f"Z値 = {z:.3f}")
print(f"p値(両側) = {p_value:.10%}")
if p_value < alpha:
print(f"有意差あり (H0を棄却)\n")
else:
print(f"有意差なし (H0を棄却できない)\n")
# 検証1:たねポケを2枚引く確率
def main1():
# CSVを読み込み
csv_path = "./deck1/summary.csv"
df = pd.read_csv(csv_path)
# 試行回数と成功回数を取得
n_trials = len(df)
k_success = (df["tane"] == 2).sum() # たねポケが2枚出現した行数をカウント
print(f"n = {n_trials}, K = {k_success}\n")
# ロジックごとの理論確率を定義
p1 = math.comb(18, 3) / math.comb(19, 4) # ロジック1
p2 = math.comb(18, 3) / (math.comb(20, 5) - math.comb(18, 5)) # ロジック2
p3 = math.comb(18, 3) / math.comb(20, 5) # ロジック3
# 各ロジックに対して検定を実行
for i, p0 in enumerate([p1, p2, p3]):
print(f"ロジック{i + 1}")
perform_z_test(n_trials, k_success, p0)
# 検証2:ネッコアラのみを引く確率
def main2():
# CSVを読み込み
csv_path = "./deck2/summary.csv"
df = pd.read_csv(csv_path)
# Only_koara 列を追加(koaraが1枚、他が0枚のとき1、それ以外は0)
df["Only_koara"] = ((df["koara"] == 1) & (df.drop(columns=["name", "koara"]) == 0).all(axis=1)).astype(int)
# 試行回数と成功回数を取得
n_trials = len(df)
k_success = df["Only_koara"].sum()
print(f"n = {n_trials}, K = {k_success}\n")
# ロジックごとの理論確率を定義
p1 = (1 / 5) * (math.comb(15, 4) / math.comb(19, 4)) # ロジック1
p2 = math.comb(15, 4) / (math.comb(20, 5) - math.comb(15, 5)) # ロジック2
p3 = math.comb(15, 4) / math.comb(20, 5) \
+ (math.comb(15, 5) / math.comb(20, 5)) * (1 / 5) # ロジック3
# 各ロジックに対して検定を実行
for i, p0 in enumerate([p1, p2, p3]):
print(f"ロジック{i + 1}")
perform_z_test(n_trials, k_success, p0)
if __name__ == '__main__':
main1()
main2()
実行環境
- アプリ:Pokémon TCG Pocket 1.2.5
- エミュレータ:BlueStacks 5
- OS:Windows 11
- Python:3.10.12
おわりに
ここまで読んでいただきありがとうございました。
もしご意見やご指摘などございましたら、ぜひコメントで教えていただけると幸いです!