筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイトの対面シミュレーター(1対1)と3対3シミュレーターで使っている「モンテカルロ法で勝率を出す部分」の実装を解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。任天堂・クリーチャーズ・ゲームフリーク・株式会社ポケモンとは関係ありません。
はじめに
ダメージ計算の「確定数」は、乱数16通り・急所・命中を状態ごとに展開すれば厳密に求められます(前々回の記事で扱いました)。ところが、対面を数ターン進めるとなると話が変わります。
- 素早さが同じなら行動順は 1/2 で入れ替わる
- 相手がどの技を選ぶかは使用率に従ってばらつく
- 相手の持ち物・特性・性格・能力ポイントの配分も、使用率データの中から決まる
- 急所・命中・追加効果・連続技の回数が毎ターン絡む
これを全部掛け合わせた状態を列挙するのは現実的ではないので、対面シミュレーターでは 1試行=1回の対戦を最後まで乱数で進め、それを何千〜何万回繰り返して数える モンテカルロ法を使っています。この記事では、その周辺の「数え方」を中心に書きます。
- 結果を再現できるシード付き乱数
- 1試行の結果を4種類に分ける
- 勝率に Wilson の 95% 信頼区間を付ける
- 使用率から相手の型を試行ごとに抽選する
- 3対3で「出す順」を全通り比べる
- テストの書き方(確率がからむ結果をどう検証するか)
1. シード付き乱数:同じ URL なら同じ結果
Math.random() はシードを指定できないので、結果を共有したり不具合を再現したりできません。そこで 32bit の小さな PRNG(mulberry32)を自前で持っています。
// battle-sim.mjs
export function rng(seed = Date.now()) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Math.imul で 32bit 整数の掛け算をしているので、どのブラウザでも同じ列が出ます。rng(1) の最初の値は何度作り直しても 0.6270739405881613 です。
画面側では、実行ボタンを押すたびに (Math.random() * 2 ** 31) >>> 0 でシードを作り、入力内容と一緒に URL のクエリ(seed=...)へ書き出しています。URL を開き直すと同じシードで同じ順番に乱数を引くので、勝率や内訳まで完全に同じ結果 になります。結果欄の下にもシードを表示しています。
シミュレーション本体は、受け取った乱数関数 r だけを使い、Math.random() は呼びません。ここを守らないと再現性が崩れます。
2. 1試行の結果は4種類
1試行は「指定ターン数まで進める」か「どちらかが倒れる」まで回します。
// duel.mjs(要点)
export function runOne(P0, turns, r, ctx = {}) {
const P = begin(P0, r, ctx); const { A, B } = P;
for (let t = 1; t <= turns; t++) {
playTurn(P, t, ctx);
if (A.hp <= 0 || B.hp <= 0)
return { res: A.hp <= 0 && B.hp <= 0 ? 'draw' : B.hp <= 0 ? 'win' : 'lose', turn: t };
}
return { res: 'stall', turn: turns };
}
win(相手だけ倒れた)・lose(自分だけ倒れた)・draw(同じターンに両方倒れた)に加えて、指定ターン内に決着がつかなかった stall を別に数えています。1対1ツールのターン数は1〜5(既定3)、3対3は1〜50(既定20)です。「3ターン以内に勝てる確率」を知りたい場面が多いので、決着しなかった試行を勝ち・負けのどちらにも混ぜないようにしました。
simulate はこれを trials 回まわして数えるだけです(あわせて、終了時の残りHPを10%ごとの区分で数え、結果画面に分布として出しています。乱数は使わないので、勝率の結果は変わりません)。進捗は chunk 回ごと(ツールから呼ぶときは500回ごと)に通知し、そのタイミングで中断も受け付けます。
const r = rng(seed ?? Date.now());
const cnt = { win: 0, lose: 0, draw: 0, stall: 0 };
let done = 0;
for (let i = 0; i < trials; i++) {
const o = runOne(P, turns, r, ctx); cnt[o.res]++; done++;
if ((i + 1) % chunk === 0) { onProgress?.((i + 1) / trials); if (shouldStop?.()) break; }
}
const out = {};
for (const k of Object.keys(cnt)) out[k] = { p: cnt[k] / done, ci: wilson(cnt[k], done), n: cnt[k] };
分母は trials ではなく実際に終わった回数 done にしています。途中で止めても、そこまでの結果として正しい割合になるようにするためです。
3. Wilson の 95% 信頼区間
モンテカルロの勝率は試行回数ぶんの誤差を持つので、数字だけ出すと「52% と 49% はどちらが強いのか」が判断できません。そこで各割合に 95% 信頼区間を付けています。
よく見る正規近似(( \hat p \pm 1.96\sqrt{\hat p(1-\hat p)/n} ))は、勝率 0% や 100% の近くで幅が 0 になったり、範囲が [0, 1] からはみ出したりします。対面シミュレーターでは「ほぼ確実に勝てる/負ける」組み合わせがよく出るので、端でも破綻しない Wilson のスコア区間を使いました。
$$
\frac{\hat p + \frac{z^2}{2n} \pm z\sqrt{\frac{\hat p(1-\hat p)}{n} + \frac{z^2}{4n^2}}}{1 + \frac{z^2}{n}}
$$
export function wilson(k, n, z = 1.96) {
if (!n) return [0, 0];
const p = k / n, d = 1 + (z * z) / n, c = p + (z * z) / (2 * n),
r = z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n));
return [Math.max(0, (c - r) / d), Math.min(1, (c + r) / d)];
}
実際の値はこうなります。
| k / n | 区間 |
|---|---|
| 50 / 100 | 0.4038 〜 0.5962 |
| 0 / 100 | 0 〜 0.0370 |
| 5000 / 10000 | 0.4902 〜 0.5098 |
0 回でも上限が 3.7% と出るので、「100回やって一度も負けなかった=負ける確率は 0%」と言い切らずに済みます。
画面では目安として、勝率 50% 付近の幅 ( 1.96\sqrt{0.25/n} ) も添えています。対面シミュレーター(1対1)の試行回数の選択肢ごとに計算すると、100回で ±9.8%、1,000回で ±3.1%、5,000回で ±1.4%、10,000回(既定)で ±1.0%、50,000回で ±0.4% です。3対3シミュレーターは1試行が重いので、選択肢は 1,000・5,000(既定)・10,000・20,000回(±0.7%)にしています。スマートフォン・タブレットでは、1対1は最大10,000回・3対3は最大5,000回に抑えています(超える回数を指定した共有URLを開いた場合はその旨を結果に表示します)。この区間はあくまで「試行回数による誤差」で、使用率データのずれや、シミュレーターが扱っていない仕様(交代・読み合いなど)による誤差は含まない、と結果欄にも書いています。
4. 使用率から相手の型を試行ごとに抽選する
相手を「使用率データのポケモン」にした場合、持ち物・特性・性格・能力ポイントの配分を試行ごとに引き直します。それぞれの採用率を合計1に正規化し、累積で引くだけです。
// sim-setup.mjs(要点)
const norm = (list) => { const t = list.reduce((s, [, r]) => s + r, 0); return t > 0 ? list.map(([v, r]) => [v, r / t]) : []; };
const draw = (r, d) => { let x = r(); for (const [v, p] of d) { x -= p; if (x < 0) return v; } return d[d.length - 1][0]; };
function sample(r) {
const item = draw(r, dist.item), ability = draw(r, dist.ability),
nature = draw(r, dist.nature), spread = draw(r, dist.spread);
const key = `${item}|${ability}|${nature}|${spread}`;
let v = cache.get(key);
if (!v) { /* 型を組み立てて cache に入れる */ }
return v;
}
draw の最後の return d[d.length - 1][0] は、浮動小数の丸めで合計がわずかに1を下回ったときの保険です。
型を組み立てる処理(実数値の計算や、技ごとのダメージ表の準備)は重いので、item|ability|nature|spread をキーにキャッシュしています。抽選は毎試行行いますが、同じ組み合わせが出たら準備済みのものを使い回します。ユーザーが持ち物などを固定した場合は、その項目だけ確率1の分布に置き換えます。
試行ごとに「どの型が引かれたか」も記録しているので、結果画面では「相手の持ち物ごとの勝率」なども出せます。この表の各行にも、同じ wilson() で信頼区間を付けています(引かれた回数が少ない型ほど区間が広くなります)。
5. 3対3:出す順を全通り比べる
3対3シミュレーターには、自分の3匹の出す順(3! = 6通り)をすべて試して比べる機能があります。
// sim-run.mjs(要点)
const perms = (xs) => (xs.length <= 1 ? [xs]
: xs.flatMap((x, i) => perms([...xs.slice(0, i), ...xs.slice(i + 1)]).map((p) => [x, ...p])));
for (const order of perms(idx)) {
const r = runTeamSim(D, { ...payload, me: order.map((i) => payload.me[i]) });
rows.push({ order, win: r.result.win, /* ... */ });
}
rows.sort((a, b) => b.win.p - a.win.p || a.k - b.k); // 同率は順列の辞書順
各順は、同じシード・同じ相手の指定 で runTeamSim を呼んでいるだけです。そのため、比較表の1行は「その順で3対3を単体で回した結果」と完全に一致します。テストでもこれを deepEqual で確かめています。途中で中断した場合は、試行回数が最後まで終わった順だけを表に残します。
6. 確率がからむ結果をどうテストするか
モンテカルロの出力は毎回ぶれる……と思いきや、シードを固定すれば決定的なので、普通の単体テストが書けます。サイトのテストでは、次の3種類を使い分けています。
(a) 確率が 0 か 1 になる状況は、完全一致で確かめる
test('素早さ: 速いほうが一撃で倒せば勝率100%', () => {
const r = simulate(prepare({ me, opp, env }), { trials: 2000, turns: 3, seed: 1 });
assert.equal(r.result.win.p, 1); assert.equal(r.result.lose.p, 0);
});
(b) 理論値がわかる状況は、「理論値が 95% 信頼区間に入るか」で確かめる
test('命中率: 50%の一撃技は1ターンで50%、2ターンで75%', () => {
const r1 = simulate(P, { trials: 20000, turns: 1, seed: 4 });
const r2 = simulate(P, { trials: 20000, turns: 2, seed: 5 });
assert.ok(r1.result.win.ci[0] < 0.5 && r1.result.win.ci[1] > 0.5);
assert.ok(r2.result.win.ci[0] < 0.75 && r2.result.win.ci[1] > 0.75);
});
同速の 1/2、使用率で技を抽選したときの割合なども同じ形で書いています。許容幅を手で決めずに、本番と同じ wilson() の区間をそのまま使えるのが利点です。
(c) 割合の合計が 1 になることを確かめる
使用率データ(シングル・ダブル)に載っている全ポケモンを相手に、200回ずつ回し、win + lose + draw + stall が 1(誤差 1e-9 以内)になることを確認しています。数え漏れや二重カウントがあればここで落ちます。
まとめ
- 結果の共有と再現のために、
Math.random()ではなくシード付きの mulberry32 を使い、シードを URL に入れています。 - 1試行の結果は win / lose / draw / stall の4つに分け、指定ターン内に決着しなかった試行を別に数えます。
- 勝率には Wilson の 95% 信頼区間を付けています。0% や 100% の近くでも破綻せず、テストの判定にもそのまま使えます。
- 相手の型は使用率から試行ごとに抽選し、重い準備処理は型の組み合わせごとにキャッシュしています。
- 3対3の出す順の比較は、同じシードで全順列を回しているだけなので、各行が単体の実行結果と一致します。
実際に動くツールはこちらです(ポケチャン攻略ラボの対面シミュレーター)。
https://pokechamp.elggum.com/tools/battle-sim/