0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポケモンの対面シミュレーターをモンテカルロ法で作る:シード付き乱数(mulberry32)・Wilsonの95%信頼区間・使用率からの型の抽選(JavaScript)

0
Posted at

筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイトの対面シミュレーター(1対1)と3対3シミュレーターで使っている「モンテカルロ法で勝率を出す部分」の実装を解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。任天堂・クリーチャーズ・ゲームフリーク・株式会社ポケモンとは関係ありません。

はじめに

ダメージ計算の「確定数」は、乱数16通り・急所・命中を状態ごとに展開すれば厳密に求められます(前々回の記事で扱いました)。ところが、対面を数ターン進めるとなると話が変わります。

  • 素早さが同じなら行動順は 1/2 で入れ替わる
  • 相手がどの技を選ぶかは使用率に従ってばらつく
  • 相手の持ち物・特性・性格・能力ポイントの配分も、使用率データの中から決まる
  • 急所・命中・追加効果・連続技の回数が毎ターン絡む

これを全部掛け合わせた状態を列挙するのは現実的ではないので、対面シミュレーターでは 1試行=1回の対戦を最後まで乱数で進め、それを何千〜何万回繰り返して数える モンテカルロ法を使っています。この記事では、その周辺の「数え方」を中心に書きます。

  1. 結果を再現できるシード付き乱数
  2. 1試行の結果を4種類に分ける
  3. 勝率に Wilson の 95% 信頼区間を付ける
  4. 使用率から相手の型を試行ごとに抽選する
  5. 3対3で「出す順」を全通り比べる
  6. テストの書き方(確率がからむ結果をどう検証するか)

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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?