「ガチャの確率」を実装したら、それは確率ではなく重み付けだった
この記事のテーマ
あるアミューズメント系システムの案件で、いわゆる「ガチャ(抽選)」機能を実装する機会がありました。仕様書には当たり前のように 「確率」 という言葉が並んでいたのですが、要件を詰めていくうちに、運用側が本当に求めていたのは確率そのものではなく 「重み付けによる出現コントロール」 だったことが分かってきます。
この記事では、確率と重み付けの違い、レア度・部位・曜日といった複数の軸で出現率をコントロールする設計、そして Laravel での具体的な実装例を紹介します。なお、数値やドメイン名はすべて匿名化・一般化しています。
動作環境
- PHP 8.2 系
- Laravel 10 系
- MySQL 8.0
- 限定的な Clean Architecture 構成(Repository / UseCase を分離)
「確率を設定したい」という依頼から始まった問題
抽選機能の仕様は、最初こう書かれていました。
> レア度ごとに確率を設定できるようにしてほしい。
一見するとシンプルな要件です。ところが、実際に運用担当者と会話をしていくと、次のような要望が次々と出てきます。
- 「このアイテムだけ、もう少し出にくくしたい」
- 「土日だけレアを出やすくしたい」
- 「“おまもり”系のアイテムは全体的に絞りたい」
これらを 「確率(合計が100%になる数値)」 で管理しようとすると、途端に運用が破綻します。というのも、1つの値をいじると他の全アイテムの確率が連動して変わってしまう からです。アイテムが増えたり減ったりするたびに、全体を再計算しなければなりません。運用担当者が「このアイテムだけ絞りたい」と思っても、その1操作が全アイテムに波及してしまうわけです。
なぜズレるのか — 運用が触りたいのは「割合」ではなかった
そもそも運用側が触りたかったのは「全体に対する厳密な割合」ではありません。「このアイテムを、相対的にどれくらい出したいか」 という感覚値でした。
つまり、必要だったのは確率ではなく 重み(weight) です。
- 重みの数値が高いアイテムほど当たりやすい
- 低いアイテムほど当たりにくい
- 各アイテムに独立した数値を振れる(他をいじらなくていい)
ここで「確率」という言葉のまま実装を進めてしまうと、UI・DB・ロジックのすべてが 「合計100%前提」 で固まってしまいます。そうなると、後から「曜日変動」のような調整軸を足したくなっても、根本から作り直しになりかねません。用語の選択が、そのまま設計の柔軟性を左右するのです。
対応方針
1. 用語を「重み付け」に統一する
まず、仕様書・管理画面・コード上の表現を 「確率」から「重み付け」へ統一 しました。エンドユーザー向けの表向きの表示は「確率」のままでも構いませんが、内部実装と管理値は重みであることをドキュメントに明記します。
そのうえで、重みの振る舞いを次のようにチーム・運用側と合意しておきます。
- 最大値と最小値の差が大きい → 最小値のアイテムがより当たりにくい
- 差が小さい → 出現率が近づく
- 全て同じ値 → 出現率差はなくなる(均等)
この一文を最初に握っておくだけで、後の調整がすべて「感覚と一致」するようになります。運用担当者が数値をいじったときの結果が直感どおりになる、というのは地味ですが非常に大きなメリットです。
2. 重みを複数の軸で持つ
出現コントロールを1つの数値に押し込めず、軸ごとに独立した重み を持たせました。
| 軸 | 範囲 | 役割 |
|---|---|---|
| レア度 | 1〜100 | レアほど数値を低くして出にくくする |
| 部位別 | 1〜100 | 部位単位で出やすさを調整(特定カテゴリを絞る等) |
| 曜日加算 | 0〜100 | レア度の出現率のみに上乗せ(0なら影響なし) |
引き当ては「関連する重みの合計」で行います。軸を分けておくことで、「土日だけレアを底上げする」といった調整を、他の軸を壊さずに 足せるようになります。
実装例
重み付き抽選の核になるのは、累積和と乱数 です。合計100%を意識する必要は一切ありません。
/**
* 重み付き抽選
*
* @param array<int, int> $weights アイテムID => 重み(0以下は除外)
* @return int 当選したアイテムID
*/
public function draw(array $weights): int
{
$weights = array_filter($weights, static fn (int $w): bool => $w > 0);
$total = array_sum($weights);
if ($total <= 0) {
throw new \RuntimeException('抽選対象の重みが0です');
}
// 1〜合計 の一様乱数。random_int は暗号論的に安全な乱数
$rand = random_int(1, $total);
$cursor = 0;
foreach ($weights as $itemId => $weight) {
$cursor += $weight;
if ($rand <= $cursor) {
return $itemId;
}
}
// 丸め誤差なども無い整数演算なので理論上到達しないが、保険
return (int) array_key_last($weights);
}
そして、複数の軸を合成して最終的な重みを組み立てる部分は、次のように分けておくと見通しが良くなります。
/**
* アイテムごとの最終重みを組み立てる。
* 曜日加算は「レア度の出現率」にだけ効くのがポイント。
*/
public function buildWeights(array $items, int $weekdayBonus): array
{
$weights = [];
foreach ($items as $item) {
// レア度の重み + 曜日加算(曜日変動が効くのはここだけ)
$rarityWeight = $item->rarityWeight + $weekdayBonus;
// 部位の重みと合算して、そのアイテムの引き当て重みを決める
$weights[$item->id] = $rarityWeight + $item->partWeight;
}
return $weights;
}
抽選ロジックは UseCase(Interactor)側に置き、DB アクセスは Repository に閉じ込めます。こうしておくと、抽選ロジックが「どこからアイテムを取ってくるか」を知らずに済むため、テストも格段に書きやすくなります。
テストによる検証
重み付き抽選は「確率的に正しいか」を検証する必要があります。とはいえ乱数をそのままテストに持ち込むと結果が不安定になるため、乱数そのものはモックし、境界値で決定的に検証 します。
public function test_境界値でちょうど当たるアイテムが選ばれる(): void
{
$weights = [
1 => 10, // 累積 1〜10
2 => 20, // 累積 11〜30
3 => 70, // 累積 31〜100
];
// random_int が 11 を返す状況を想定した検証
// (実装では乱数生成を注入可能にしておく)
$this->assertSame(2, $this->service->drawWith($weights, rand: 11));
$this->assertSame(1, $this->service->drawWith($weights, rand: 10));
$this->assertSame(3, $this->service->drawWith($weights, rand: 31));
}
分布そのものを確認したい場合は、大量に試行して各アイテムの出現割合が「重み比」に収束するかを、緩い許容誤差 でアサートします。これは統計的な検証になるため、誤差幅は広めに取っておくのが現実的です。
実装・運用時の注意点
- 乱数は
rand()ではなくrandom_int()を使う。 偏りと予測可能性を避けるためです。 - 重みは整数で持つ。 小数にすると累積和の丸め誤差が生じ、デバッグが難しくなります。
- 合計を固定しない。 アイテムの追加・削除のたびに全体を再計算しなくて済むことが、重み付けの最大の利点です。
- 「曜日加算が効く軸」を明確にする。 今回は「レア度のみ」に効かせました。ここが曖昧だと、運用側の期待と実装がズレます。
- 表示は「確率」でも実装は「重み」 と割り切り、その差をドキュメントに残しておく。
まとめ
「確率を実装してほしい」という依頼の裏にあったのは、確率そのものではなく 相対的な出やすさ=重み付け でした。用語を早い段階で「重み付け」に統一し、出現コントロールを複数の独立した軸に分けたことで、後から来た「曜日だけ調整したい」といった要望も破綻なく取り込むことができました。
抽選機能を作るときは、まず 「これは確率か、重みか」 を運用側と握るところから始める。たったこれだけで、実装も運用もぐっと楽になります。仕様書に書かれた言葉をそのまま実装に落とすのではなく、その言葉の裏にある本当の要求を確かめること。それが、後々の設計の破綻を防ぐ一番のポイントだと感じた案件でした。
