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?

公平なランダム抽選を実装する:crypto.getRandomValues()と重み付きルーレット

0
Posted at

Web でランダムルーレットを作るとき、円を描いて CSS で回すところまでは難しくありません。実装で本当に注意すべきなのは、選択確率を偏らせず、利用者が結果を確認・説明できる状態にすることです。

授業の指名、抽選会、チーム分けのように結果が重要な用途では、「それっぽく止まる」だけでは不十分です。本記事では GoSpinWheel の実装経験から、ランダム抽選ロジック、重み付き選択、状態管理、入力検証をコード中心に整理します。

結果は回転前に決める

アンチパターンは、CSS アニメーションが終わった角度から当選項目を逆算する実装です。この方法では描画の丸め、フレームレート、アニメーション中断が抽選ロジックに入り込みます。

処理は次の 4 段階に分けます。

  1. 配列から当選インデックスを選ぶ
  2. 当選スライスの中央に対応する停止角度を求める
  3. その角度まで回転させる
  4. アニメーション完了後に、最初に選んだ値を表示する

抽選関数が DOM や Canvas に依存しなければ、単体テストが簡単です。モーション軽減やアニメーションのスキップにも確率を変えずに対応できます。

crypto.getRandomValues() で一様なインデックスを作る

Math.random() はシミュレーションや演出には便利ですが、信頼性を前面に出す抽選には Web Crypto API を使うほうが適切です。

ただし、次のような実装には剰余バイアスがあります。

// 2^32 が length で割り切れない場合、一部の値がわずかに多くなる
return randomUint32 % length;

上端の余りを捨てる棄却サンプリングで回避できます。

function randomIndex(length) {
  if (!Number.isSafeInteger(length) || length <= 0) {
    throw new RangeError("length must be a positive safe integer");
  }

  const range = 2 ** 32;
  const limit = range - (range % length);
  const value = new Uint32Array(1);

  do {
    crypto.getRandomValues(value);
  } while (value[0] >= limit);

  return value[0] % length;
}

[0, limit) の範囲だけを採用すれば、各インデックスに割り当てられる 32 bit 値の個数が同じになります。通常の項目数なら再試行はほぼ発生しません。

GoSpinWheel の通常スピンでは、ブラウザ標準の crypto.getRandomValues() を抽選結果に使っています。アルゴリズムと乱数源を公開情報として説明できることも、ユーザーの安心につながります。

重み付き抽選の実装

各項目に重みを設定する場合、重み 2 は重み 1 の 2 倍の当選確率を意味します。重みの合計を区間として扱うとシンプルに実装できます。

function pickWeighted(entries, randomUnit) {
  const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
  let target = randomUnit * total;

  for (const entry of entries) {
    if (target < entry.weight) return entry;
    target -= entry.weight;
  }

  return entries.at(-1);
}

本番コードでは、関数を呼ぶ前に次を検証します。

  • 重みが有限値か
  • 負数ではないか
  • 正の重みが 1 つ以上あるか
  • 合計が安全に扱える範囲か

randomUnit を引数にすると、0、境界の直前、1 に近い値などを注入でき、テストしやすくなります。本番では Web Crypto API から偏りのない値を生成します。

また、UI には重みだけでなく entry.weight / total * 100 で求めた確率も表示します。「重み 3」より「当選確率 25%」のほうが利用者に伝わります。

表示状態と抽選対象を別々に持つ

抽選後に当選項目を除外するモードでは、次の状態を混ぜないようにします。

{
  label: "Team A",
  weight: 1,
  hidden: false,    // ラベルを見せるか
  eligible: true    // 抽選対象か
}

hidden はミステリー表示のため、eligible は確率計算のためです。ラベルを隠しただけで抽選対象から外れてしまう実装は、利用者の予想と一致しません。

当選後は結果を表示してから除外し、履歴と Undo / Reset を用意します。内部状態が変化したタイミングを UI に反映することが大切です。

ローカルファーストで個人情報を守る

抽選項目には氏名や社内情報が入ることがあります。入力と同時にサーバーへ送信する必要はありません。

基本機能はブラウザ内で完結させます。

  • 匿名編集・抽選:メモリ
  • 端末保存:Local Storage など
  • バックアップ:明示的な JSON エクスポート
  • クラウド保存/公開 URL:利用者が選択した場合だけ送信

GoSpinWheel でも、匿名利用者は編集、抽選、CSV/TSV の読み込み、JSON の書き出し、端末保存をローカルで行えます。通信が必要な機能を分離すると、プライバシーだけでなくレスポンスや障害耐性も改善します。

CSV / TSV を安全に読み込む

インポートデータは信頼せず、最低限次を実装します。

  • ファイルサイズと項目数の上限
  • nameoptionweightprobability など許可するヘッダー
  • 反映前のプレビュー
  • 空文字、不正な重み、極端に長い文字列の検証
  • 数式や HTML を実行しない

GoSpinWheel は現在、300 KB までのテキスト/CSV/TSV、最大 1,100 スライスに制限しています。上限を明示すると、パースや描画に必要なリソースを予測できます。

分布を自動テストする

1 回の結果ではなく、多数回の頻度を検証します。

均等抽選なら、すべての項目が選ばれ、観測回数が期待値から大きく外れていないことを確認します。重み付き抽選なら、観測比率を期待確率と比較します。統計テストだけに頼らず、次の境界条件も個別にテストします。

  • 項目数 1
  • 重み 0
  • 極端な重み差
  • 重複ラベル
  • 削除と Undo
  • 複数ルーレットの同時処理
  • reduced motion

まとめ

公平なオンライン抽選は、ホイールの回転表現だけでは作れません。

  • 結果とアニメーションを分離する
  • Web Crypto API と棄却サンプリングを使う
  • 重みから導出した確率を表示する
  • 表示、抽選対象、履歴を別々に管理する
  • 個人データを初期状態では端末内に置く
  • インポートを検証し、分布をテストする

この設計なら、画面効果を外してもルールが変わらず、利用者に仕組みを説明できるランダム抽選ツールになります。

この記事は GoSpinWheel の開発知見をもとに、DEV Community の英語版を Qiita 読者向けに再構成しました。

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?